IdentityServer4 Authentication for Sitecore - Part 2

Oct 03, 2019
Byron Calisto

In part 1 of this series, we configured a custom identity provider using IdentityServer4 framework and ASP.NET Core. This web application was created and deployed as an independent site in IIS (since it is an ASP.NET Core web app it can also be deployed to other types of web servers). In this post we will configure our Sitecore site so it uses our custom identity provider for authentication. Note: if you read my previous article Authenticating Public Website Users With Sitecore 9.1+ and Facebook, you will see similar (and repeated) concepts, code and configurations. This is because we are using the same Sitecore Federated Authentication functionality to achieve this integration.

What do you need?

We are using Sitecore 9.1 Update-1 (9.1.1), so the following NuGet package list (with the libraries you will need for your module's .NET project) are based on what is compatible with Sitecore 9.1.1. For other versions, please check that you use the correct versions of the packages in your Sitecore installation bin directory:

Package Name Version
Sitecore.Abstractions 9.1.1
Sitecore.Kernel 9.1.1
Sitecore.Mvc 9.1.1
Sitecore.Owin.Authentication 9.1.1
Owin 1.0.0
Microsoft.AspNet.Identity.Core 2.2.1
Microsoft.AspNet.Identity.Owin 2.2.1
Microsoft.AspNet.Mvc 5.2.4
Microsoft.AspNet.Razor 3.2.7
Microsoft.AspNet.WebPages 3.2.7
Microsoft.Extensions.DependencyInjection.Abstractions 2.1.1
Microsoft.IdentityModel.Protocols 5.2.2
Microsoft.IdentityModel.Protocols.OpenIdConnect 5.2.2
Microsoft.Owin 4.0.0
Microsoft.Owin.Security 4.0.0
Microsoft.Owin.Security.OpenIdConnect 4.0.0

Also, don't forget to complete the IdentityServer4-based identity provider setup as discussed on Part 1 of this series. For this post, we are assuming the identity provider was deployed to a site accessible through https://test-is4.oshyn.com

For brevity, I have removed the "using" declaration blocks from the code samples.

How to implement it

First of all, we will do some basic infrastructure to read our settings from the config patch file. The following class contains 4 properties that correspond to each of the settings we will be reading from our patch file:

Is4Settings.cs
namespace Oshyn.Auth.Settings
{
    public class Is4Settings
    {
        private readonly BaseSettings _settings;
        private readonly string _prefix = "Oshyn.Auth.Is4.";
 
        public virtual string ClientId => _settings.GetSetting($"{_prefix}ClientId");
        public virtual string Authority => _settings.GetSetting($"{_prefix}Authority");
        public virtual string RedirectUri => _settings.GetSetting($"{_prefix}RedirectUri");
        public virtual string PostLogoutRedirectUri => _settings.GetSetting($"{_prefix}PostLogoutRedirectUri");
 
        public Is4Settings(BaseSettings baseSettings)
        {
            _settings = baseSettings;
        }
    }
}

We also create an extension for BaseSettings so it maps to our custom settings:

SettingsExtensions.cs
namespace Oshyn.Auth.Extensions
{
    public static class SettingsExtensions
    {
        public static Is4Settings GetIs4Settings(this BaseSettings settings)
        {
            Assert.ArgumentNotNull(settings, "settings");
            return new Is4Settings(settings);
        }
    }
}

Once we have these infrastructure classes and extensions set up, we can create our identity provider pipeline/processor. Here is the code, with an analysis below:

Is4ProviderProcessor.cs
namespace Oshyn.Auth.Pipelines
{
    public class Is4ProviderProcessor : IdentityProvidersProcessor
    {
        protected override string IdentityProviderName => "IS4";
 
        //List of scopes requested from IdentityServer4
        public Collection<string> Scopes { get; } = new Collection<string>();
 
        public Is4ProviderProcessor(
                FederatedAuthenticationConfiguration federatedAuthenticationConfiguration,
                ICookieManager cookieManager,
                BaseSettings baseSettings) :
            base(federatedAuthenticationConfiguration, cookieManager, baseSettings) { }
 
        protected override void ProcessCore(IdentityProvidersArgs args)
        {
            Assert.ArgumentNotNull(args, "args");
 
            var identityProvider = GetIdentityProvider();
 
            //Using our extension to read our custom settings
            var is4Settings = Settings.GetIs4Settings();
 
            var openIdConnectAuthOptions = new OpenIdConnectAuthenticationOptions
            {
                AuthenticationMode = AuthenticationMode.Passive,
                AuthenticationType = identityProvider.Name,
                Authority = is4Settings.Authority, //Value from settings
                Caption = identityProvider.Caption,
                ClientId = is4Settings.ClientId, //Value from settings
                CookieManager = CookieManager,
                PostLogoutRedirectUri = is4Settings.PostLogoutRedirectUri, //Value from settings
                RedirectUri = is4Settings.RedirectUri, //Value from settings
                ResponseType = "id_token", //We are using Implicit grant in IdentityServer4, so this must be "id_token"
                Scope = string.Join(" ", Scopes),
                UseTokenLifetime = false,
                Notifications = new OpenIdConnectAuthenticationNotifications
                {
                    SecurityTokenValidated = message =>
                    {
                        var identity = message.AuthenticationTicket.Identity;
 
                        //This is based from a solution by Sean Sartell to allow Sitecore to correctly log out
                        identity.AddClaim(new Claim("id_token", message.ProtocolMessage.IdToken));
 
                        //Apply claim transformations
                        message.AuthenticationTicket.Identity.ApplyClaimsTransformations(
                            new TransformationContext(FederatedAuthenticationConfiguration, identityProvider)
                        );
 
                        message.AuthenticationTicket = new AuthenticationTicket(identity, message.AuthenticationTicket.Properties);
 
                        return Task.CompletedTask;
                    },
                    RedirectToIdentityProvider = message =>
                    {
                        //The following code is based from Sean Sartell solution for correct Sitecore logout redirection
                        var revokeProperties = message.OwinContext.Authentication.AuthenticationResponseRevoke?.Properties?.Dictionary;
                         
                        if (revokeProperties != null && revokeProperties.ContainsKey("nonce") && revokeProperties.ContainsKey("id_token"))
                        {
                            var uri = new Uri(message.ProtocolMessage.PostLogoutRedirectUri);
                            var host = uri.GetComponents(UriComponents.SchemeAndServer, UriFormat.Unescaped);
                            var path = $"/{uri.GetComponents(UriComponents.Path, UriFormat.Unescaped)}";
                            var nonce = revokeProperties["nonce"];
 
                            message.ProtocolMessage.PostLogoutRedirectUri = $"{host}/identity/postexternallogout?ReturnUrl={path}&nonce={nonce}";
 
                            //This is not in Sean Sartell's solution, but it is needed for IdentityServer4 to
                            //redirect from the identity provider back to the Sitecore postexternallogout handler
                            message.ProtocolMessage.IdTokenHint = revokeProperties["id_token"];
                        }
 
                        return Task.CompletedTask;
                    }
                }
            };
 
            args.App.UseOpenIdConnectAuthentication(openIdConnectAuthOptions);
        }
    }
}

Let's quickly analyze this class:

  • There is a Scopes public property that accepts a Collection of strings. This is passed through the configuration patch file. The scopes must match the ones we have defined in the IdentityServer4 Client definition as seen in Part 1, and are configured in the config patch file that we'll analyze later.
  • In ProcessCore, we basically define the OpenID Connect configurations to connect to our IdentityServer4 provider:
    • We use the extension method defined previously to directly read our custom settings from the config patch file.
    • We create the options object, and pass the required fields. Notice that Authority, ClientId, PostLogoutRedirectUri and RedirectUri fields are pulled from our custom configuration values.
    • There are 2 async notifications (events) that we are implementing custom code: SecurityTokenValidated and RedirectToIdentityProvider.
    • In the SecurityTokenValidated event, we apply claims transformations. This should be done even if you don't have any transformations defined in your config patch file. Also, it adds the id_token to the authentication ticket. This is based on a solution by Sean Sartell to correctly log out on the Sitecore side after IdentityServer4 is logged out.
    • In the RedirectToIdentityProvider event, we use a code mostly based from Sean Sartell's solution to rebuild the PostLogoutRedirectUri. You might remember this in Part 1, where our IdentityServer4 provider expects a URI with a path set to "/identity/postexternallogout". This URI is built with 2 parameters, ReturnUrl and nonce. The ReturnUrl is constructed from the PostLogoutRedirectUri setting in our config patch. The nonce value is taken from the revokeProperties set when a logout is triggered. These 2 parameters are required by the Sitecore.Owin.Authentication.Pipelines.Initialize.HandlePostLogoutUrl pipeline, that triggers a cleanup on the Sitecore side after IdentityServer4 redirects when logging out. Something that isn't included in Sean Sartell's solution, but it is required by IdentityServer4 to automatically redirect to the specified PostLogoutRedirectUri, is setting the "id_token_hint" parameter when triggering the logout in IdentityServer4. This is achieved by setting the IdTokenHint property in the protocol message, using the "id_token" value from the revokeProperties object.

Now we need to build our configuration patch file that needs to be deployed under App_Config/Include:

Oshyn.Auth.config
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"
               xmlns:set="http://www.sitecore.net/xmlconfig/set/"
               xmlns:role="http://www.sitecore.net/xmlconfig/role/"
               xmlns:security="http://www.sitecore.net/xmlconfig/security/">
  <sitecore role:require="Standalone or ContentDelivery or ContentManagement">
    <!-- These are our custom settings -->
    <settings>
      <setting name="Oshyn.Auth.Is4.ClientId" value="openIdConnectClient" />
      <setting name="Oshyn.Auth.Is4.Authority" value="https://test-is4.oshyn.com" /> <!-- This is our IdentityServer4 host name -->
      <setting name="Oshyn.Auth.Is4.RedirectUri" value="https://sc911.oshyn.com/signin-is4" />
      <setting name="Oshyn.Auth.Is4.PostLogoutRedirectUri" value="https://sc911.oshyn.com/" />
    </settings>
 
    <pipelines>
      <owin.identityProviders>
        <!-- Here we define our provider processor pipeline -->
        <processor type="Oshyn.Auth.Pipelines.Is4ProviderProcessor, Oshyn.Auth" resolve="true">
          <scopes hint="list">
            <!-- These are the scopes requested from IdentityServer4 -->
            <scope name="openid">openid</scope>
            <scope name="profile">profile</scope>
            <scope name="email">email</scope>
          </scopes>
        </processor>
      </owin.identityProviders>
    </pipelines>
 
    <federatedAuthentication
        type="Sitecore.Owin.Authentication.Configuration.FederatedAuthenticationConfiguration, Sitecore.Owin.Authentication">
      <identityProvidersPerSites hint="list:AddIdentityProvidersPerSites">
        <!-- This mapEntry is specific for our public website-->
        <mapEntry name="Public website"
                  type="Sitecore.Owin.Authentication.Collections.IdentityProvidersPerSitesMapEntry, Sitecore.Owin.Authentication">
          <sites hint="list">
            <!-- Change it if you have a different name for the site in your <site name=""...> definition -->
            <site>website</site>
          </sites>
 
          <identityProviders hint="list:AddIdentityProvider">
            <!-- This points to our custom IdentityServer4 "IS4" provider defined in the identityProviders section -->
            <identityProvider ref="federatedAuthentication/identityProviders/identityProvider[@id='IS4']" />
          </identityProviders>
 
          <!-- We are using our custom user builder to use the email address as the username -->
          <!-- Also we are setting isPersistentUser to false, so no new users are persisted in Sitecore -->
          <externalUserBuilder type="Oshyn.Auth.UserBuilders.CustomUserBuilder, Oshyn.Auth">
            <param desc="isPersistentUser">false</param>
          </externalUserBuilder>
        </mapEntry>
      </identityProvidersPerSites>
 
      <identityProviders hint="list:AddIdentityProvider">
        <!-- External identity provider configuration -->
        <identityProvider id="IS4"
                          type="Sitecore.Owin.Authentication.Configuration.DefaultIdentityProvider, Sitecore.Owin.Authentication">
          <param desc="name">$(id)</param>
          <param desc="domainManager" type="Sitecore.Abstractions.BaseDomainManager" resolve="true" />
          <caption>Log in with IdentityServer4</caption>
          <!-- Make sure your icon file exists in your filesystem -->
          <icon>/Assets/icons/is4.ico</icon>
          <domain>extranet</domain>
          <!-- The following setting is very important, it triggers logout in IdentityServer4 -->
          <triggerExternalSignOut>true</triggerExternalSignOut>
        </identityProvider>
      </identityProviders>
       
      <!-- General profile property mappings from the IdentityServer4 claims -->
      <propertyInitializer type="Sitecore.Owin.Authentication.Services.PropertyInitializer, Sitecore.Owin.Authentication">
        <maps hint="list">
          <map name="Name to FullName"
               type="Sitecore.Owin.Authentication.Services.DefaultClaimToPropertyMapper, Sitecore.Owin.Authentication"
               resolve="true">
            <data hint="raw:AddData">
              <source name="name" />
              <target name="FullName" />
            </data>
          </map>
          <map name="Emailaddress to Email"
               type="Sitecore.Owin.Authentication.Services.DefaultClaimToPropertyMapper, Sitecore.Owin.Authentication"
               resolve="true">
            <data hint="raw:AddData">
              <source name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" />
              <target name="Email" />
            </data>
          </map>
          <map name="Comment"
               type="Sitecore.Owin.Authentication.Services.DefaultClaimToPropertyMapper, Sitecore.Owin.Authentication"
               resolve="true">
            <data hint="raw:AddData">
              <source name="idp" />
              <target name="Comment" />
            </data>
          </map>
        </maps>
      </propertyInitializer>
    </federatedAuthentication>
  </sitecore>
</configuration>

Let's quickly analyze this configuration patch file:

  • Our custom settings are defined under the <settings> section. These are read by our custom Is4Settings class using the extension method and accessed as shown in the Is4ProviderProcessor pipeline. For this example, we are explicitly configuring the RedirectUri and PostLogoutRedirectUri values with absolute URLs, since we haven't implemented an automated way to obtain the hostname. You can improve this and automatically obtain the hostname, and configure in these values only the relative paths. The path for RedirectUri MUST be "/signin-[name_of_provider]", and the name of the provider must match the name used for the IdentityProviderName property in the Is4ProviderProcessor class (in this example, IS4)
  • In pipelines/owin.identityProviders/processor, we point it to our Is4ProviderProcessor class. We pass a <scopes> list with the scopes we want to retrieve from IdentityServer4. In this example, we are retrieving all the scopes allowed from our custom IdentityServer4 provider (openid, profile, email). These scopes contain different types of claims that we can use in our Sitecore application.
  • In federatedAuthentication/identityProvidersPerSites/mapEntry, we map our public website (defined in your Site Configuration patch file as <site name=website ...>) to use our custom identity provider. The name must match the value used for the IdentityProviderName property in the Is4ProviderProcessor class. We also specify a custom user builder, so our external users' usernames are not random, but use the users' email addresses (this class is described later). Also we set the isPersistentUser flag to false, so none of the external users are persisted in Sitecore's core database.
  • In federatedAuthentication/identityProviders/identityProvider, we configure the external identity provider. Make sure the triggerExternalSignOut flag is set to true, since this will allow IdentityServer4 to be logged out when a logout is triggered from your site.
  • In federatedAuthentication/propertyInitializer, we map IdentityServer4's claims to Sitecore profile properties, so then can be easily accesible using the Sitecore.Context.User.Profile properties.

The only thing already included in the configuration but not yet described is the custom user builder. This simple class will use the IdentityServer4 user's email and map it as the username for the virtual Sitecore user:

CustomUserBuilder.cs
namespace Oshyn.Auth.UserBuilders
{
    public class CustomUserBuilder : DefaultExternalUserBuilder
    {
        public CustomUserBuilder(bool isPersistentUser) :
            base(ServiceLocator.ServiceProvider.GetService<ApplicationUserFactory>(),
                 ServiceLocator.ServiceProvider.GetService<IHashEncryption>())
        {
            IsPersistentUser = isPersistentUser;
        }
 
        public CustomUserBuilder(string isPersistentUser) :
            this(bool.Parse(isPersistentUser)) { }
 
        protected override string CreateUniqueUserName(UserManager<ApplicationUser> userManager, ExternalLoginInfo externalLoginInfo)
        {
            Assert.ArgumentNotNull(userManager, "userManager");
            Assert.ArgumentNotNull(externalLoginInfo, "externalLoginInfo");
 
            var identityProvider =
                FederatedAuthenticationConfiguration.GetIdentityProvider(externalLoginInfo.ExternalIdentity);
 
            if (identityProvider == null)
            {
                throw new InvalidOperationException("Unable to retrieve identity provider");
            }
 
            return $"{identityProvider.Domain}\\{externalLoginInfo.Email}";
        }
    }
}

All the code and classes defined cover all the Federated Authentication configuration we must do for Sitecore to work with your IdentityServer4 custom identity provider defined in Part 1. But we need a way to test this functionality. To test this, we are going to create 2 simple Controller Renderings and Views to quickly handle login, user info and logout.

This is the Controller Rendering and View for the Login (don't forget to create the Controller Rendering definition in Sitecore under /sitecore/layout/Renderings):

LoginController.cs
namespace Oshyn.Auth.Controllers
{
    public class LoginController : Controller
    {
        public ActionResult LoginProviderList()
        {
            var corePipelineManager = ServiceLocator.ServiceProvider.GetService<BaseCorePipelineManager>();
            var args = new GetSignInUrlInfoArgs("website", "/");
 
            GetSignInUrlInfoPipeline.Run(corePipelineManager, args);
 
            return View("~/Views/Authentication/LoginProviderList.cshtml", args.Result);
        }
    }
}
LoginProviderList.cshtml
@model IEnumerable<Sitecore.Data.SignInUrlInfo>
 
@foreach (var signIn in Model)
{
    using (Html.BeginForm(null, null, FormMethod.Post, new { @action = signIn.Href}))
    {
        <button type="submit">
            <img src="@signIn.Icon"/>
            @signIn.Caption
        </button>
    }
}

This is the Controller Rendering and View for User Info and Logout button (also don't forget to create the Controller Rendering definition in Sitecore under /sitecore/layout/Renderings):

UserInfoController.cs
namespace Oshyn.Auth.Controllers
{
    public class UserInfoController : Controller
    {
        public ActionResult UserInfo()
        {
            return View("~/Views/Authentication/UserInfo.cshtml");
        }
 
        [HttpPost]
        public ActionResult Logout()
        {
            Sitecore.Security.Authentication.AuthenticationManager.Logout();
            return Redirect("/"); //Mostly irrelevant since redirection is handled from the HandlePostLogoutUrl pipeline.
        }
    }
}
UserInfo.cshtml
<div id="user-info">
    <span>User name (test): @Sitecore.Context.User.Name</span>
    <span>Full Name: @Sitecore.Context.User.Profile.FullName</span>
    <span>Email: @Sitecore.Context.User.Profile.Email</span>
</div>
<div id="logout">
    @using (Html.BeginRouteForm(Sitecore.Mvc.Configuration.MvcSettings.SitecoreRouteName, FormMethod.Post))
    {
        @Html.Sitecore().FormHandler("UserInfo", "Logout")
        <button type="submit">Logout</button>
    }
</div>

In Sitecore (or Sitecore Rocks), create two pages; one for the Home, and another under the Home called Login. Assign the UserInfo rendering to the Home page, and the Login rendering to the Login page. Make sure your site definition points to your Home page. Publish the page and start testing. Go first to your Home page (in our specific case, we configured our server as sc911.oshyn.com, so we navigated to https://sc911.oshyn.com). Make sure the Anonymous user is the one active, as shown in this screenshot:

Anyonymous user logged in screenshot

Navigate to your Login page (in our specific case, https://sc911.oshyn.com/login). You will only see a button that will redirect you to the IdentityServer4 login page (enhancement idea: automatically redirect to the IdentityServer4 login page when navigating to /login, using the signIn.Href address)

Log in with IdentityServer4 button

Click on the Log in with IdentityServer4 button, and you will automatically redirected to the IdentityServer4 login page. Let's login using our "testuser" user we created in Part 1 of this series:

IdentityServer4 login page screenshot

After successful login, you will be automatically redirected to the Home page. Now you will see the full logged-in user information:

logged-in user information screenshot

Click on the Logout button. You will be automatically redirected to IdentityServer4 again, but it will be very brief. If everything has been correctly configured as per this guide, you will be automatically redirected again to the Home page, and see the default\Anonymous user.

References