Friday, May 10, 2019

Federated Authentication with Insite Identity Server - Part 1

My co-worker Nick Agnostopolus and I just went through a process of figuring out how to configure Sitecore Federated Authentication in 9.1.1 to use Insite Identity Server as an OpenID Connect provider. After a few long days we spent reflecting the Insite and Sitecore code, we have arrived at the solution. A few customizations had to be done on Insite side to make the whole thing work. Below is a detailed overview of what was done.

Configuring Insite Identity Provider in Sitecore

InsiteIdentity pipeline processor

First, a pipeline processor for Insite Identity had to be created. This is the class where most of the OpenID Connect magic happens.

    public class InsiteIdentity : IdentityProvidersProcessor
    {
        public const string PostLogoutEndPointName = "postinsitelogout";

        private readonly string oauthTokenEndpoint = "connect/token";

        private readonly string oauthUserInfoEndpoint = "connect/userinfo";

        public InsiteIdentity(
            FederatedAuthenticationConfiguration federatedAuthenticationConfiguration,
            ICookieManager cookieManager,
            BaseSettings settings)
            : base(federatedAuthenticationConfiguration, cookieManager, settings)
        {
        }

        public Collection<string> Scopes { get; } = new Collection<string>();

        protected override string IdentityProviderName => "InsiteIdentity";

        protected IdentityProvider IdentityProvider { get; set; }

        private string ClientId
        {
            get
            {
                return Settings.GetSetting("InsiteIdentity.ClientId", "Sitecore");
            }
        }

        private string ClientSecret
        {
            get
            {
                return Settings.GetSetting("InsiteIdentity.ClientSecret");
            }
        }

        private string Authority
        {
            get
            {
                return Settings.GetSetting("InsiteIdentity.Authority");
            }
        }

        private string CallbackAuthority
        {
            get
            {
                return Settings.GetSetting("InsiteIdentity.CallbackAuthority");
            }
        }

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

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

            args.App.UseOpenIdConnectAuthentication(this.CreateOpenIdConnectAuthenticationOptions(authenticationType, this.IdentityProvider));
        }

        protected string BuildPostLogoutRedirectUri(IOwinContext owinContext, string authority)
        {
            Assert.ArgumentNotNull((object)owinContext, nameof(owinContext));
            Uri uri = owinContext.Request.Uri;
            if (string.IsNullOrEmpty(authority))
            {
                authority = uri.GetLeftPart(UriPartial.Authority);
            }

            return InsiteIdentity.CombineUrlPath(authority, this.Settings.IdentityProcessingPathPrefix(), InsiteIdentity.PostLogoutEndPointName) + "?ReturnUrl" + "=" + HttpUtility.UrlEncode(uri.ToString());
        }

        private static string CombineUrlPath(params string[] parts)
        {
            List<string> list = ((IEnumerable<string>)parts).Where<string>((Func<string, bool>)(p => !string.IsNullOrEmpty(p))).ToList<string>();
            string str = string.Join("/", list.Select<string, string>((Func<string, string>)(p => p.Trim('/'))));
            if (list.First<string>()[0] == '/')
            {
                str = "/" + str;
            }

            if (list.Last<string>()[0] == '/')
            {
                str += "/";
            }

            return str;
        }

        private OpenIdConnectAuthenticationOptions CreateOpenIdConnectAuthenticationOptions(string authenticationType, IdentityProvider identityProvider)
        {
            var options = new OpenIdConnectAuthenticationOptions
            {
                Caption = this.IdentityProvider.Caption,
                AuthenticationType = authenticationType,
                AuthenticationMode = AuthenticationMode.Passive,
                ClientId = this.ClientId,
                ClientSecret = this.ClientSecret,
                Authority = this.Authority,
                ResponseType = OpenIdConnectResponseType.CodeIdTokenToken,
                Scope = string.Join(" ", (IEnumerable<string>)this.Scopes),
                UseTokenLifetime = false,
                CookieManager = this.CookieManager,

                Notifications = new OpenIdConnectAuthenticationNotifications
                {
                    SecurityTokenValidated = async notification =>
                    {
                        await SecurityTokenValidated(notification);
                    },
                    RedirectToIdentityProvider = notification =>
                    {
                        return RedirectToIdentityProvider(notification);
                    },
                }
            };

            options.TokenValidationParameters.SaveSigninToken = identityProvider.TriggerExternalSignOut;
            return options;
        }

        private async Task SecurityTokenValidated(SecurityTokenValidatedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> notification)
        {
            UserInfoResponse userInfoResponse;
            using (UserInfoClient userInfoClient = new UserInfoClient(notification.Options.Authority.EnsureTrailingSlash() + this.oauthUserInfoEndpoint))
            {
                LogManager.Diagnostic("[Sitecore Identity] auth_code: " + notification.ProtocolMessage.AccessToken, this);
                userInfoResponse = await userInfoClient.GetAsync(notification.ProtocolMessage.AccessToken, new CancellationToken()).ConfigureAwait(false);
            }

            var additionalClaims = new TransformationContextWithAdditionalClaims(this.FederatedAuthenticationConfiguration, this.IdentityProvider);
            if (userInfoResponse.IsError)
            {
                LogManager.Error("[Sitecore Identity] Unable to get user info - " + userInfoResponse.Error, userInfoResponse.Exception, this);
                LogManager.Diagnostic("[Sitecore Identity] userInfoResponse.Response.Raw: " + userInfoResponse.Raw, this);
            }
            else
            {
                additionalClaims.AdditionalClaims = userInfoResponse.Claims;
            }

            this.AddCustomClaims(notification, userInfoResponse);

            notification.AuthenticationTicket.Identity.ApplyClaimsTransformations((TransformationContext)additionalClaims);

            var transformationContext = new TransformationContextWithAdditionalClaims(this.FederatedAuthenticationConfiguration, this.IdentityProvider);
            foreach (Transformation transformation in (transformationContext.FederatedAuthenticationConfiguration?.SharedClaimsTransformations ?? Enumerable.Empty<Transformation>()).Concat((IEnumerable<Transformation>)transformationContext.IdentityProvider.Transformations))
            {
                transformation.Transform(notification.AuthenticationTicket.Identity, transformationContext);
            }
        }

        private void AddCustomClaims(SecurityTokenValidatedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> notification, UserInfoResponse userInfoResponse)
        {
            var firstName = string.Empty;
            var lastName = string.Empty;

            ////Retrieve the claim given_name, and assign to first_name
            if (userInfoResponse.Claims.Any(k => k.Type.Contains("given_name")))
            {
                firstName = userInfoResponse.Claims.FirstOrDefault(k => k.Type.Contains("given_name")).Value;
            }

            ////The claim "family_name" is what was getting returned from the info
            if (userInfoResponse.Claims.Any(k => k.Type.Contains("family_name")))
            {
                lastName = userInfoResponse.Claims.FirstOrDefault(k => k.Type.Contains("family_name")).Value;
            }

            ////Add a custom claim, which is then transformed to the Sitecore FullName field.
            notification.AuthenticationTicket.Identity.AddClaim(new Claim("ExternalFullName", firstName + " " + lastName));

            ////Add another custom claim for comments:
            notification.AuthenticationTicket.Identity.AddClaim(new Claim("xComment", "Insite User"));

            notification.AuthenticationTicket.Identity.AddClaim(new Claim("insite_role", @"CommerceUsers\\Extranet User"));
        }

        private Task RedirectToIdentityProvider(RedirectToIdentityProviderNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> notification)
        {
            var owinContext = notification.OwinContext;

            OpenIdConnectMessage protocolMessage = notification.ProtocolMessage;

            if (protocolMessage.RequestType == OpenIdConnectRequestType.Authentication)
            {
                protocolMessage.AcrValues = this.GetAcrValues(owinContext);
                protocolMessage.RedirectUri = this.BuildRedirectUri(owinContext, "signin");
                protocolMessage.Prompt = this.GetSuitablePrompt(owinContext, "login");
            }
            else if (protocolMessage.RequestType == OpenIdConnectRequestType.Logout && this.GetIdentityProvider().TriggerExternalSignOut)
            {
                protocolMessage.IdTokenHint = this.GetIdTokenHint(owinContext);
                protocolMessage.PostLogoutRedirectUri = this.BuildPostLogoutRedirectUri(owinContext, string.Empty);
            }

            return Task.CompletedTask;
        }
    }

An InsiteIdentityProvider class was implemented:


    public class InsiteIdentityProvider : DefaultIdentityProvider
    {
        public InsiteIdentityProvider(BaseDomainManager domainManager, BaseCorePipelineManager corePipelineManager) : base("InsiteIdentity", domainManager)
        {
            Assert.ArgumentNotNull((object)corePipelineManager, nameof(corePipelineManager));
            this.CorePipelineManager = corePipelineManager;
        }

        protected BaseCorePipelineManager CorePipelineManager { get; }
    }

Foundation.Account.config file

The following configurations were added to Foundation.Account.config file.

<?xml version="1.0" encoding="utf-8"?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:set="http://www.sitecore.net/xmlconfig/set/">
    <sitecore>
        <sc.variable name="insiteIdentityServerAuthority" value="https://xxxinsite.local.com" />
        <settings>
            <setting name="InsiteIdentity.ClientId" value="xxx" />
            <setting name="InsiteIdentity.ClientSecret" value="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" />
            <setting name="InsiteIdentity.OAuthRedirectUri" value="/identity/externallogincallback" />
            <setting name="InsiteIdentity.Authority" value="$(insiteIdentityServerAuthority)/identity" />
            <setting name="InsiteIdentity.CallbackAuthority" value="/identity/externallogincallback" />
        </settings>
        <domainManager>
            <domains>
                <domain id="modules" type="Sitecore.Security.Domains.Domain, Sitecore.Kernel">
                    <param desc="name">$(id)</param>
                    <ensureAnonymousUser>false</ensureAnonymousUser>
                </domain>
            </domains>
        </domainManager>
        <pipelines>
            <accounts.loggedIn />
            <accounts.loggedOut />
            <accounts.registered />
            <owin.initialize>
                <processor type="Sitecore.Owin.Authentication.IdentityServer.Pipelines.Initialize.JwtBearerAuthentication, Sitecore.Owin.Authentication.IdentityServer"
                           patch:before="processor[@method='Authenticate']" resolve="true" >
                    <identityProviderName>InsiteIdentity</identityProviderName>
                    <audiences hint="raw:AddAudience">
                        <audience value="$(insiteIdentityServerAuthority)/resources"/>
                    </audiences>
                    <issuers hint="list">
                        <issuer>$(insiteIdentityServerAuthority)</issuer>
                    </issuers>
                </processor>
                <processor patch:before="*[@type='Sitecore.Owin.Authentication.Pipelines.Initialize.HandlePostLogoutUrl, Sitecore.Owin.Authentication']" mode="on" type="Xc.Foundation.Account.Infrastructure.Pipelines.Initialize.HandlePostLogoutUrl, Xc.Foundation.Account" resolve="true"/>
            </owin.initialize>
            <owin.identityProviders>
                <processor id="InsiteIdentity" type="Xc.Foundation.Account.Infrastructure.Pipelines.IdentityProviders.InsiteIdentity, Xc.Foundation.Account" resolve="true">
                    <scopes hint="list">
                        <scope name="openid">openid</scope>
                        <scope name="profile">profile</scope>
                        <scope name="iscapi">iscapi</scope>
                    </scopes>
                </processor>
            </owin.identityProviders>
        </pipelines>

        <federatedAuthentication type="Sitecore.Owin.Authentication.Configuration.FederatedAuthenticationConfiguration, Sitecore.Owin.Authentication">
            <!--Provider mappings to sites-->
            <identityProvidersPerSites hint="list:AddIdentityProvidersPerSites">
            </identityProvidersPerSites>

            <!--Definitions of providers-->
            <identityProviders hint="list:AddIdentityProvider">

                <identityProvider id="InsiteIdentity" type="Xc.Foundation.Account.Infrastructure.Providers.InsiteIdentityProvider, Xc.Foundation.Account" resolve="true">
                    <caption>Sign In</caption>
                    <domain>CommerceUsers</domain>
                    <enabled>true</enabled>
                    <triggerExternalSignOut>true</triggerExternalSignOut>

                    <transformations hint="list:AddTransformation">
                        <transformation name="apply additional claims" type="Sitecore.Owin.Authentication.IdentityServer.Transformations.ApplyAdditionalClaims, Sitecore.Owin.Authentication.IdentityServer" resolve="true"/>
                        <transformation name="name to long name" type="Sitecore.Owin.Authentication.Services.DefaultTransformation, Sitecore.Owin.Authentication">
                            <sources hint="raw:AddSource">
                                <claim name="preferred_username"/>
                            </sources>
                            <targets hint="raw:AddTarget">
                                <claim name="http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"/>
                            </targets>
                            <keepSource>true</keepSource>
                        </transformation>
                        <transformation name="role to long role" type="Sitecore.Owin.Authentication.Services.DefaultTransformation, Sitecore.Owin.Authentication">
                            <sources hint="raw:AddSource">
                                <claim name="insite_role"/>
                            </sources>
                            <targets hint="raw:AddTarget">
                                <claim name="http://schemas.microsoft.com/ws/2008/06/identity/claims/role" value="CommerceUsers\Extranet User"/>
                            </targets>
                            <keepSource>false</keepSource>
                        </transformation>
                        <transformation name="set ShadowUser" type="Sitecore.Owin.Authentication.Services.DefaultTransformation, Sitecore.Owin.Authentication">
                            <sources hint="raw:AddSource">
                                <claim name="http://schemas.microsoft.com/identity/claims/identityprovider" value="local"/>
                            </sources>
                            <targets hint="raw:AddTarget">
                                <claim name="http://www.sitecore.net/identity/claims/shadowuser" value="true"/>
                            </targets>
                            <keepSource>true</keepSource>
                        </transformation>
                        <!--
                            owin.cookieAuthentication.signIn pipeline uses http://www.sitecore.net/identity/claims/cookieExp claim to override authentication cookie expiration.
                                         'exp' claim value can be configured on Sitecore Identity server on the client configuration by IdentityTokenLifetimeInSeconds setting.
                                         Note: Claim value is Unix time expressed as the number of seconds that have elapsed since 1970-01-01T00:00:00Z 
                        -->
                        <transformation name="use exp claim for authentication cookie expiration" type="Sitecore.Owin.Authentication.Services.DefaultTransformation, Sitecore.Owin.Authentication">
                            <sources hint="raw:AddSource">
                                <claim name="exp"/>
                            </sources>
                            <targets hint="raw:AddTarget">
                                <claim name="http://www.sitecore.net/identity/claims/cookieExp"/>
                            </targets>
                            <keepSource>true</keepSource>
                        </transformation>
                        <!--<transformation name="remove local role claims" type="Sitecore.Owin.Authentication.IdentityServer.Transformations.RemoveLocalRoles, Sitecore.Owin.Authentication.IdentityServer"/>-->
                        <transformation name="adjust NameIdentifier claim" type="Sitecore.Owin.Authentication.IdentityServer.Transformations.AdjustNameIdentifierClaim, Sitecore.Owin.Authentication.IdentityServer" resolve="true"/>
                    </transformations>
                </identityProvider>
            </identityProviders>

            <!--List of all shared transformations-->
            <sharedTransformations>
            </sharedTransformations>

            <!--Property mappings initializer-->
            <propertyInitializer type="Sitecore.Owin.Authentication.Services.PropertyInitializer, Sitecore.Owin.Authentication">
                <!--List of property mappings
                Note that all mappings from the list will be applied to each providers-->
                <maps hint="list">
                    <!--The mapping sets the Email property of the user profile from emailaddress claim-->
                    <map name="email claim" type="Sitecore.Owin.Authentication.Services.DefaultClaimToPropertyMapper, Sitecore.Owin.Authentication" resolve="true">
                        <data hint="raw:AddData">
                            <!--claim name-->
                            <source name="preferred_username" />
                            <!--property name-->
                            <target name="Email" />
                        </data>
                    </map>
                    <map name="first_name" type="Sitecore.Owin.Authentication.Services.DefaultClaimToPropertyMapper, Sitecore.Owin.Authentication" resolve="true">
                        <data hint="raw:AddData">
                            <source name="given_name" />
                            <target name="FirstName" />
                        </data>
                    </map>
                    <map name="full name" type="Sitecore.Owin.Authentication.Services.DefaultClaimToPropertyMapper, Sitecore.Owin.Authentication" resolve="true">
                        <data hint="raw:AddData">
                            <source name="ExternalFullName" />
                            <target name="FullName" />
                        </data>
                    </map>
                    <map name="comment" type="Sitecore.Owin.Authentication.Services.DefaultClaimToPropertyMapper, Sitecore.Owin.Authentication" resolve="true">
                        <data hint="raw:AddData">
                            <source name="xComment" />
                            <target name="Comment" />
                        </data>
                    </map>
                    <map name="role" type="Sitecore.Owin.Authentication.Services.DefaultClaimToPropertyMapper, Sitecore.Owin.Authentication" resolve="true">
                        <data hint="raw:AddData">
                            <source name="insite_role" />
                            <target name="http://schemas.microsoft.com/ws/2008/06/identity/claims/role" />
                        </data>
                    </map>
                </maps>
            </propertyInitializer>
        </federatedAuthentication>
    </sitecore>
</configuration>

Project.XXX.Website.config

In the Project layer, I added the following configurations:

        <federatedAuthentication  role:require="ContentDelivery or Standalone">
            <identityProvidersPerSites>
                <mapEntry name="atccorg" type="Sitecore.Owin.Authentication.Collections.IdentityProvidersPerSitesMapEntry, Sitecore.Owin.Authentication">
                    <sites hint="list">
                        <site>xxx</site>
                    </sites>
                    <identityProviders hint="list:AddIdentityProvider">
                        <identityProvider ref="federatedAuthentication/identityProviders/identityProvider[@id='InsiteIdentity']"  id="InsiteIdentity"  />
                    </identityProviders>
                    <externalUserBuilder type="Xc.Foundation.Account.Services.InsiteUserBuilder, Xc.Foundation.Account">
                        <param desc="isPersistentUser">true</param> 
                    </externalUserBuilder>
                </mapEntry>
            </identityProvidersPerSites>
        </federatedAuthentication>

InsiteUserBuilder.cs

User builder class had to be overwritten as well:

    public class InsiteUserBuilder : Sitecore.Owin.Authentication.Services.DefaultExternalUserBuilder
    {
        public InsiteUserBuilder()
        {
        }

        public InsiteUserBuilder(bool isPersistentUser) : base(isPersistentUser)
        {
        }

        public InsiteUserBuilder(string isPersistentUser) : base(isPersistentUser)
        {
        }

        public override ApplicationUser BuildUser(UserManager<ApplicationUser> userManager, ExternalLoginInfo externalLoginInfo)
        {
            ApplicationUser user = this.ApplicationUserFactory.CreateUser(this.CreateUniqueUserName(userManager, externalLoginInfo));
            user.IsVirtual = !this.IsPersistentUser;
            return user;
        }

        protected override string CreateUniqueUserName(UserManager<ApplicationUser> userManager, ExternalLoginInfo externalLoginInfo)
        {
            IdentityProvider identityProvider = this.FederatedAuthenticationConfiguration.GetIdentityProvider(externalLoginInfo.ExternalIdentity);
            if (identityProvider == null)
            {
                throw new InvalidOperationException("Unable to retrieve an identity provider for the given identity");
            }

            return identityProvider.Domain + "\\" + externalLoginInfo.ExternalIdentity.Name;
        }
    }

HandlePostLogoutUrl.cs

    public class HandlePostLogoutUrl : InitializeProcessor
    {
        private readonly BaseSettings settings;

        private readonly ExternalLogoutManager externalLogoutManager;

        public HandlePostLogoutUrl(BaseSettings settings, ExternalLogoutManager externalLogoutManager)
        {
            Assert.ArgumentNotNull((object)settings, nameof(settings));
            Assert.ArgumentNotNull((object)externalLogoutManager, nameof(externalLogoutManager));
            this.settings = settings;
            this.externalLogoutManager = externalLogoutManager;
        }

        public override void Process(InitializeArgs args)
        {
            if (!this.settings.FederatedAuthenticationEnabled())
            {
                return;
            }

            this.HandlePostExternalLogoutUrl(args);
        }

        private void HandlePostExternalLogoutUrl(InitializeArgs args)
        {
            args.App.Map(
                this.settings.IdentityProcessingPathPrefix().EnsureTrailingSlash() + InsiteIdentity.PostLogoutEndPointName,
                (Action<IAppBuilder>)(app => this.RunUnRegisterExternalLogout(app)));
        }

        private void RunUnRegisterExternalLogout(IAppBuilder app)
        {
            app.Run((Func<IOwinContext, Task>)(context => { this.UnRegisterExternalLogout(context); return Task.CompletedTask; }));
        }

        private void UnRegisterExternalLogout(IOwinContext owinContext)
        {
            Assert.ArgumentNotNull((object)owinContext, nameof(owinContext));
            owinContext.Response.Cookies.Delete(
                Sitecore.Owin.Authentication.Constants.ExternalLogOutCookieName, 
                new CookieOptions()
            {
                HttpOnly = true,
                Path = "/"
            });
            string str = owinContext.Request.Query["ReturnUrl"];
            if (string.IsNullOrEmpty(str) || WebUtil.IsExternalUrl(str, owinContext.Request.Uri.Host))
            {
                owinContext.Response.Redirect("/");
            }

            owinContext.Response.Redirect(str);
        }
    }

Site Definition

In the site definition the following attribute was added:

loginPage="$(loginPath)[xxxsitename]/InsiteIdentity"

Login Button

Controller Rendering:

 public ActionResult SignIn()
        {
            var url = !string.IsNullOrEmpty(Request.QueryString[Constants.QueryString.Url])
                        ? HttpUtility.UrlDecode(Request.QueryString[Constants.QueryString.Url])
                        : Request.RawUrl;

            var model = new LoginInfo
            {
                ReturnUrl = !string.IsNullOrEmpty(Request.QueryString[Constants.QueryString.ReturnUrl])
                    ? HttpUtility.UrlDecode(Request.QueryString[Constants.QueryString.ReturnUrl])
                    : url,
                LoginButton = this.fedAuthLoginRepository.GetAll().FirstOrDefault()
            };

            return this.View(model);
        }

View:

In the view responsible for rendering of the login button, the following code was added:

        if (Model.LoginButton != null)
        {
            <form action="@Model.LoginButton.Href" method="post" class="form-signin">
                <button class="btn btn-block btn-secondary" type="submit">
                    @if (!string.IsNullOrWhiteSpace(Model.LoginButton.IconClass))
                    {
                        <i class="@Model.LoginButton.IconClass"></i>
                    }
                    <span>
                        @Model.LoginButton.Caption
                    </span>
                </button>
            </form>
        }

LogOut

Controller action:

       [HttpGet]
        public ActionResult SignOut()
        {
            this.accountRepository.Logout();
            return this.Redirect("/");
        }

In AccountRepository we have the following:

        public bool LogOut()
        {          
            AuthenticationManager.Logout();
            return true;
        }

Above code and configurations force the user to be redirected to Insite Identity Server for authentication and log out.

Special Thanks to the following blogs:

No comments:

Post a Comment