When you develop an enterprise website you might need to allow your visitors to login into your website to have a unique and personalized experience. Single Sign-On allows to your audience to enter credentials just one time instead of enter the password for each web application that your organization offers.

In the marketplace, there are many identity providers such as Google, Facebook, Microsoft, Github, etc. In enterprise environments you might have your own Azure Active Directory (Azure AD) which you store all your users information.

Sitecore provides a documentation which I found helpful to understand what needs to be configured in order to implement Federated Authentication for front-end login.

Sitecore Documentation:

https://doc.sitecore.com/xp/en/developers/102/sitecore-experience-manager/configure-federated-authentication.html

In this blog post I will explain Federated Authentication with Azure AD using OpenID Connect (OIDC) which is an authentication protocol based on the OAuth2 protocol. For more details please read the following article: https://learn.microsoft.com/en-us/azure/active-directory/fundamentals/auth-oidc

Configure an Identity Provider

First step, you need to create a config file for example, in my Sitecore instance I have a Foundation.Accounts project, so I created a file Foundation.Accounts.SSO.config

<?xml version="1.0" encoding="utf-8" ?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
  <sitecore>
    <federatedAuthentication type="Sitecore.Owin.Authentication.Configuration.FederatedAuthenticationConfiguration, Sitecore.Owin.Authentication">
     
    </federatedAuthentication>
  </sitecore>
</configuration>

Then inside federatedAuthentication we should add the following elements identityProvidersPerSites, identityProviders and propertyInitializer

identityProvidersPerSites add a MapEntry element which contains which sites the identity provider will be used. As I mentioned in this article, SSO will be used for website login.

In externalUserBuilder, I´m using a custom UserBuilder and IsPersistentUser is set to false. Because we don´t need to have unnecessary users in the CMS database. In other words, our users will be virtual users.

  <identityProvidersPerSites>
        <mapEntry name="ADMapEntry" type="Sitecore.Owin.Authentication.Collections.IdentityProvidersPerSitesMapEntry, Sitecore.Owin.Authentication" resolve="true">
          <sites hint="list">
            <site>website</site>
          </sites>
          <identityProviders hint="list:AddIdentityProvider">
            <identityProvider ref="federatedAuthentication/identityProviders/identityProvider[@id='AzureADIdentityProvider']" />
          </identityProviders>
          <externalUserBuilder type="Foundation.Accounts.Services.AzureActiveDirectory.ADExternalUserBuilder, Foundation.Accounts" resolve="true">
            <IsPersistentUser>false</IsPersistentUser>
          </externalUserBuilder>
        </mapEntry>
      </identityProvidersPerSites>
  public class ADExternalUserBuilder : DefaultExternalUserBuilder
    {
        public ADExternalUserBuilder(ApplicationUserFactory applicationUserFactory, IHashEncryption hashEncryption) : base(applicationUserFactory, hashEncryption) { }

        protected override string CreateUniqueUserName(UserManager<ApplicationUser> manager, ExternalLoginInfo externalLoginInfo)
        {
            if (externalLoginInfo == null) return "nullUserInfo";

            if (string.IsNullOrWhiteSpace(externalLoginInfo.Email))
            {
                var validUserName = externalLoginInfo.DefaultUserName;

                return $"{Constants.Domains.Extranet}\\{validUserName.Replace(" ", "")}";
            }

            return $"{Constants.Domains.Extranet}\\{externalLoginInfo.Email.Replace(" ", "")}";
        }
    }

Next section identityProviders is important because here we need to specify how claims are transformed for Sitecore Login and Business requirements. Notice that domain is extranet because our users are not part of the Sitecore domain.

<identityProviders hint="list:AddIdentityProvider">
        <identityProvider id="AzureADIdentityProvider" type="Sitecore.Owin.Authentication.Configuration.DefaultIdentityProvider, Sitecore.Owin.Authentication" >
          <param desc="name">$(id)</param>
          <param desc="domainManager" type="Sitecore.Abstractions.BaseDomainManager" resolve="true" />
          <caption>Go to login</caption>
          <domain>extranet</domain>
          <enabled>true</enabled>
          <triggerExternalSignOut>true</triggerExternalSignOut>
          <transformations hint="list:AddTransformation">
            <transformation name="Idp Claim" type="Sitecore.Owin.Authentication.Services.SetIdpClaimTransform, Sitecore.Owin.Authentication" />
            <transformation name="set id_token claim" type="Sitecore.Owin.Authentication.Services.SaveIdTokenInClaim, Sitecore.Owin.Authentication" />
            <transformation name="set role claim" type="Foundation.Accounts.Services.AzureActiveDirectory.SetRoleInClaim, Foundation.Accounts" />
            <transformation name="Name Identifier Claim" type="Sitecore.Owin.Authentication.Services.DefaultTransformation, Sitecore.Owin.Authentication">
              <sources hint="raw:AddSource">
                <claim name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn" />
              </sources>
              <targets hint="raw:AddTarget">
                <claim name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier" />
              </targets>
              <keepSource>false</keepSource>
            </transformation>
          </transformations>
        </identityProvider>
      </identityProviders>

For Azure AD Identity Provider I found that those 3 transformations are required. I created a custom transformation to resolve the user’s role as SetRoleInClaim.

<transformation name="Idp Claim" type="Sitecore.Owin.Authentication.Services.SetIdpClaimTransform, Sitecore.Owin.Authentication" />

<transformation name="set id_token claim" type="Sitecore.Owin.Authentication.Services.SaveIdTokenInClaim, Sitecore.Owin.Authentication" />

<transformation name="Name Identifier Claim" type="Sitecore.Owin.Authentication.Services.DefaultTransformation, Sitecore.Owin.Authentication">
              <sources hint="raw:AddSource">
                <claim name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn" />
              </sources>
              <targets hint="raw:AddTarget">
                <claim name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier" />
              </targets>
              <keepSource>false</keepSource>

Finally the last part is to intialize User variables like Email, Name, Last Name in propertyInitializer element.

 <propertyInitializer>
        <maps hint="list">
          <map name="email claim" type="Sitecore.Owin.Authentication.Services.DefaultClaimToPropertyMapper, Sitecore.Owin.Authentication" resolve="true">
            <data hint="raw:AddData">
              <!--claim name-->
              <source name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress" />
              <!--property name-->
              <target name="Email" />
            </data>
          </map>
          <map name="Name claim" 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/givenname" />
              <target name="Name" />
            </data>
          </map>
        </maps>
      </propertyInitializer>

Integrate with the owin.IdentityProviders Pipeline.

Next, you must integrate the code into the owin.identityProviders pipeline.

   <pipelines>
      <owin.identityProviders>
        <processor type="Foundation.Accounts.Services.AzureActiveDirectory.AzureADIdentityProviderProcessor, Foundation.Accounts" resolve="true" />
      </owin.identityProviders>
    </pipelines>

Add code for the provider

 public class AzureADIdentityProviderProcessor : IdentityProvidersProcessor
    {
        public AzureADIdentityProviderProcessor(FederatedAuthenticationConfiguration federatedAuthenticationConfiguration, ICookieManager cookieManager, BaseSettings settings) : base(federatedAuthenticationConfiguration, cookieManager, settings)
        {
        }

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

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

            var targetHostName = GetTargetHostName();
            string aadInstance = "https://login.microsoftonline.com/{0}/";
            string tenant = Settings.GetSetting("Foundation.Accounts.SSO.AD.Tenant");
            string clientId = Settings.GetSetting("Foundation.Accounts.SSO.AD.ClientId");
            string clientSecret = Settings.GetSetting("Foundation.Accounts.SSO.AD.ClientSecret");
            string postLogoutRedirectURI = $"{targetHostName}{Settings.GetSetting("Foundation.Accounts.SSO.AD.PostLogoutRedirectURI")}";
            string redirectURI = $"{targetHostName}{Settings.GetSetting("Foundation.Accounts.SSO.AD.RedirectURI")}";
            string scope = Settings.GetSetting("Foundation.Accounts.SSO.AD.Scope");

            string authority = string.Format(CultureInfo.InvariantCulture, aadInstance, tenant);

            //Set the authentication type to use cookies
            args.App.UseCookieAuthentication(new CookieAuthenticationOptions
            {
                AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
            });


            args.App.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
            {
                Caption = identityProvider.Caption,
                AuthenticationType = authenticationType,
                AuthenticationMode = AuthenticationMode.Passive,
                ClientId = clientId,
                ClientSecret = clientSecret,
                Authority = authority,
                PostLogoutRedirectUri = postLogoutRedirectURI,
                RedirectUri = redirectURI,
                ResponseType = OpenIdConnectResponseType.CodeIdToken,
                UseTokenLifetime = false,
                CookieManager = this.CookieManager,
                Scope = scope,
                Notifications = new OpenIdConnectAuthenticationNotifications
                {
                    SecurityTokenValidated = notification =>
                    {
                        var identity = notification.AuthenticationTicket.Identity;
                        foreach (var claimTransformationService in identityProvider.Transformations)
                        {
                            claimTransformationService.Transform(identity,
                                new TransformationContext(FederatedAuthenticationConfiguration, identityProvider));

                        }

                        notification.AuthenticationTicket = new AuthenticationTicket(identity, notification.AuthenticationTicket.Properties);

                        return Task.FromResult(0);
                    },
                    RedirectToIdentityProvider = message =>
                    {
                        // format redirect URI so Sitecore cleans up after itself
                        var revokeProperties = message.OwinContext.Authentication.AuthenticationResponseRevoke?.Properties?.Dictionary;
                        if (revokeProperties != null && revokeProperties.ContainsKey("nonce"))
                        {
                            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"];

                            // for single sign-out, Sitecore expects the URI used below with the nonce in the query string
                            // this URI was found in Sitecore.Owin.Authentication.Pipelines.Initialize.HandlePostLogoutUrl
                            message.ProtocolMessage.PostLogoutRedirectUri = $"{host}/identity/postexternallogout?ReturnUrl={path}&nonce={nonce}";
                        }

                        return Task.FromResult(0);
                    }

                },
                TokenValidationParameters =
                {
                    SaveSigninToken = saveSigninToken
                }
            });

    
        }

        protected override string IdentityProviderName => "AzureADIdentityProvider";
    }

This part is when I had a lot of issues, for me it is important to understand the settings. You can set up an enterprise application for Azure AD, then you can provide to the webapp the following fields: Tenant, Client Id and Client Secret.

Also is very important that you set up correctly the Redirect URL in your Azure AD Application, for example, in our case: https://sitecore-instance.dev/identity/externallogincallback.

Note:

Probably for .Net experts is obviously that redirect url will be something like targethostname + /identity/externallogincallback. However I spent a lot of time trying to figured out which is the callback url. Hopefully this article saves your time if this is your first ASP.NET Identity implementation.

SettingValue
Foundation.Accounts.SSO.AD.TenantAzure Tenant
Foundation.Accounts.SSO.AD.ClientIdAzure Application Client Id
Foundation.Accounts.SSO.AD.ClientSecretAzure Application Client Secret
Foundation.Accounts.SSO.AD.PostLogoutRedirectURI/identity/postexternallogout
Foundation.Accounts.SSO.AD.RedirectURI/identity/externallogincallback
Foundation.Accounts.SSO.AD.Scopeopenid profile roles

Generate sign-in links:

Finally, I created a AuthManager class in order to retrieved the sign-in link, where I can place it for example, in the header component.

 public SignInUrlInfo GetLoginUrl(string redirectUrl = "/")
        {
            var corePipelineManager =
                (BaseCorePipelineManager)Sitecore.DependencyInjection.ServiceLocator.ServiceProvider.GetService(typeof(BaseCorePipelineManager));
            var args = new GetSignInUrlInfoArgs(site: "website", returnUrl: redirectUrl);
            GetSignInUrlInfoPipeline.Run(corePipelineManager, args);
            var result = args.Result.FirstOrDefault();
            return result;
        }

Conclusion

To quickly recap, It is necessary to create a config file to place federatedAuthentication configurations and a processor in our case AzureADIdentityProviderProcessor to configure owin middleware. Then, you need to override ProcessCore method to add args.App.UseOpenIdConnectAuthentication configuration in IdentityProvidersProcessor implementation. Finally generate sign-in links and you are all set. It sounds easy but for me it took some days of research and trying to understand what values I need to include in the config files. I hope with this example you can save a lot of time and it could be helpful to understand this Sitecore feature.