OAuth 2.0 and OpenID implementation in Sitecore Website

Hello everyone, In this blog we will try to see how we can implement OAuth 2.0 and OpenID for Authentication and Authorization. In previous blog post we discuss what is OAuth 2.0 and OpenID and how Sitecore uses the same for Authentication and Authorization. This blog will give you a basic idea that how we can follow Sitecore approach to integrate third party Identity provider. So let's get started. Would recommend everyone to read my previous article on OAuthAndOpenID

The first step will be, to create a client page with login button. This button will be responsible for transfering you to Identity Server. We will see how Sitecore does it and will try to use the same logic for our login button logic.

Create a Login Controller. This controller will give us the required SignInInfo details which will be passed to Identity Server. You can refer below code. We have created a Login Action method which will call the GetSignInInfo method to get the identity url

       
protected readonly BaseCorePipelineManager _baseCorePipelineManager;
private Collection GetSignInInfo(string returnUrl = "/")
{
  var corePipelineManager = DependencyResolver.Current.GetService();
  var args = new GetSignInUrlInfoArgs(Context.Site.Name, returnUrl);
  GetSignInUrlInfoPipeline.Run(corePipelineManager, args);
  return args.Result;
}
public ActionResult Login()
{
   //return url is Your Home page Url
   var returnUrl = "";
   bool isAutoSignIn = false;
   LoginModel loginMobel = new LoginModel
   {
     SigninInfo = GetSignInInfo(returnUrl),
     IsAutoSignIn = isAutoSignIn
   };
   return View("LoginLink.cshtml", loginMobel);
}
  

The View Code (LoginLink.cshtml). In this file create a Submit form action(basically the login form) which will pass your details to Identity Server. The signIn.Href will give you the Identity Server URL

       

@foreach (var signIn in Model.SigninInfo)
{
	using (Html.BeginForm(null, null, FormMethod.Post, new { @action = signIn.Href }))
    {
        <button type="submit" class="loginBtn" name="@nameAttribute">@signInCaption</button>
    }
}
	   
 

The LogIn Model

       
    public class LoginModel
    {
        public Collection SigninInfo { get; set; }
	public bool IsAutoSignIn { get; set; }
    }
	   
 

This will form an action pointing to /identity/externallogin with some query string parameters. The parameters are Authentication type which is your Identity provider, The Return url where the Identity server will return you the Auth Code and last is your Site Name

The Second Step is to define and add our own custom identity provider processor to default owin.identityProviders list. You can refer below screenshot which indicate the default identity processor used by Sitecore.

In our custom identity processor we will define the scope for the Authenticated user.Along with that, if we have multiple website we can define the Identity Provider name also. So let us see how we can impelement custom identity provider processor and patch it in the config. we will add the necessary parameter required for Authetication and Authroization in same patch config

Let us see the CustomIdentityProvidersProcessor and the logic which can be implemented. We will be overriding default IdentityProvidersProcessor

       
namespace ShrikantTest.Feature.IdentityLogin.Pipelines
{
    public class CustomIdentityProvidersProcessor : IdentityProvidersProcessor
    {
        protected override string IdentityProviderName = ""

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

         public JwtSecurityTokenHandler _jwtTokenHandler;

        public(FederatedAuthenticationConfiguration federatedAuthenticationConfiguration,
                ICookieManager cookieManager,
                BaseSettings baseSettings) :
            base(federatedAuthenticationConfiguration, cookieManager, baseSettings)
        {
            _jwtTokenHandler = new JwtSecurityTokenHandler();
        }

        protected override void ProcessCore(IdentityProvidersArgs args)
        {
            var identityProvider = GetIdentityProvider();

            var openIdConnectAuthOptions = new OpenIdConnectAuthenticationOptions
            {
                //Below options are available for OpenIdConnect
				Authority = "",
                AuthenticationType = GetAuthenticationType(),
                AuthenticationMode = AuthenticationMode.Passive,
                MetadataAddress = "",
                ClientId = ClientId,
                RedirectUri = RedirectUri,
                PostLogoutRedirectUri = PostLogoutRedirectUri,
                ResponseType = ResponseType,
                Scope = string.Join(" ", Scopes),
                Caption = identityProvider.Caption,
                CookieManager = CookieManager,
                UseTokenLifetime = false,
                SaveTokens = true,
                RedeemCode = true,
                TokenValidationParameters = new TokenValidationParameters()
                {
                    NameClaimType = "name",
                    SaveSigninToken = identityProvider.TriggerExternalSignOut
                },

                Notifications = new OpenIdConnectAuthenticationNotifications
                {
                    RedirectToIdentityProvider = OnRedirectToIdentityProvider,
                    MessageReceived = OnMessageReceived,
                    SecurityTokenReceived = OnSecurityTokenReceived,
                    SecurityTokenValidated = OnSecurityTokenValidated,
                    AuthorizationCodeReceived = OnAuthorizationCodeReceived,
                    AuthenticationFailed = OnAuthenticationFailed,
                    TokenResponseReceived = OnTokenResponseRecieved,
                }
            };

            args.App.UseOpenIdConnectAuthentication(openIdConnectAuthOptions);
        }

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

            OpenIdConnectMessage protocolMessage = context.ProtocolMessage;

            if (protocolMessage.RequestType == OpenIdConnectRequestType.Authentication)
            {
                //Add the redirect URI
                protocolMessage.RedirectUri = redirectUri;
                context.Options.RedirectUri = redirectUri;
            }
            else if (protocolMessage.RequestType == OpenIdConnectRequestType.Logout && GetIdentityProvider().TriggerExternalSignOut)
            {
                protocolMessage.PostLogoutRedirectUri = "{Logout Url}";
            }

            return Task.CompletedTask;
        }

        private Task OnMessageReceived(MessageReceivedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> context)
        {
            return Task.CompletedTask;
        }

        private Task OnSecurityTokenReceived(SecurityTokenReceivedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> context)
        {
            return Task.CompletedTask;
        }

        private Task OnSecurityTokenValidated(SecurityTokenValidatedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> context)
        {
            //In this method one can read the token recieved from IDP, read the claims from the token, Add the claims to current Authentication ticket
			//Add new custom claim
			//Claims transformation
            
            return Task.CompletedTask;
        }

        private async Task OnAuthorizationCodeReceived(AuthorizationCodeReceivedNotification context)
        {
            if (!string.IsNullOrEmpty(context.Code))
            {
                if (tokenResponse.IsError)
                {
                    //Exception handling and Logging
                }

                context.HandleCodeRedemption(tokenResponse.AccessToken, tokenResponse.IdentityToken);

            }
        }

        private Task OnAuthenticationFailed(AuthenticationFailedNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> context)
        {
           return Task.CompletedTask;
        }

        private Task OnTokenResponseRecieved(TokenResponseReceivedNotification context)
        {
            return Task.CompletedTask;
        }
		
	public string GetValueFromClaims(List claims, string claimType)
        {
            return claims.SingleOrDefault(x => string.Compare(x.Type, claimType, StringComparison.OrdinalIgnoreCase) == 0)?.Value;
        }
        
    }
}
	   
 

If we see the above custom Identity processor, it has all the end to end tasks. This task will start the flow from clicking the login button to passing the required parameter to Identity Server, Return Auth Code from Identity server to Client application after correct login credentials. Then again passing the Auth Code with Client ID and Secret to Identity Server to get the access token. All the Configurable parameter will be read from Site Setting from web config. Once we get the Access token, we can get the claims after transformation from SUB to NameIdentifier. Based on the Claims, we can get the User Status and assign a new domain and role required as a new claim. You can refer below code to get the User status and assign domain and role as a new Claim.

       
var userStatus = GetValueFromClaims(claims, ClaimType);
if (userStatus != null && userStatus.Equals({definerole}, StringComparison.InvariantCultureIgnoreCase))
{
    identity.AddClaim(new Claim("http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
    $"{identityProvider.Domain}\\{definerole}"));
}
	   
 

We will create our own Custom User builder by overriding default DefaultExternalUserBuilder.

       
namespace ShrikantTest.Feature.IdentityLogin.Pipelines
{
    public class CustomUserBuilder : DefaultExternalUserBuilder
    {
        public CustomUserBuilder() :
                    base(ServiceLocator.ServiceProvider.GetService<ApplicationUserFactory>(),
                         ServiceLocator.ServiceProvider.GetService<IHashEncryption>()){}

        protected override string CreateUniqueUserName(UserManager<ApplicationUser> userManager, ExternalLoginInfo externalLoginInfo)
        {
            
            var identityProvider =
                FederatedAuthenticationConfiguration.GetIdentityProvider(externalLoginInfo.ExternalIdentity);

            if (identityProvider == null)
            {
                //Exception handling and Logging
            }

            //Get the required unique user identfier from the claim. It can be id,UserName or email etc.
            var username = GetValueFromClaims(externalLoginInfo.ExternalIdentity.Claims.ToList(), UserName);
            return $"{identityProvider.Domain}\\{username}";
        }
        public string GetValueFromClaims(List claims, string claimType)
        {
            return claims.SingleOrDefault(x => string.Compare(x.Type, claimType, StringComparison.OrdinalIgnoreCase) == 0)?.Value;
        }
    }
}
	   
 

The code is straight forward where we are building a user profile based on username. If we need to do any analytics related stuff after login we can add one more pipline which will inherit from SignedInProcessor. We will override the process method to do our own custom logic for starting analytics or identifying contacts etc.

       
namespace ShrikantTest.Feature.IdentityLogin.Pipelines
{
 public class IdentityCheckProcessor : SignedInProcessor
 {
   public override void Process(SignedInArgs args)
    {
	   //Todo Analytics code logic
    }
 }
}
	   
 

Now lets see the Config Settings required for the same

       
<?xml version="1.0" encoding="utf-8" ?>
<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 ContentManagement or ContentDelivery">
    <settings>
      <setting name="Authority" value="" />
      <setting name="MetadataAddress" value="" />
      <setting name="RedirectUri" value="/externallogincallback/" />
      <setting name="PostLogoutRedirectUri" value="/" />
      <setting name="ResponseType" value="code" />
      <setting name="DefaultRole" value="extranet\Member" />
      <setting name="TenantId" value="" />
      <setting name="ClientId" value="" />
      <setting name="ClientSecret" value="" />
    </settings>

    <pipelines>
	<owin.identityProviders>
        <processor type="ShrikantTest.Feature.IdentityLogin.Pipelines.CustomIdentityProvidersProcessor, ShrikantTest.Feature.IdentityLogin" resolve="true" desc="Site1IDP">
          <idpName>Site1IDP</idpName>
          <scopes hint="list">
            <scope name="openid">openid</scope>
            <scope name="profile">profile</scope>
            <scope name="email">email</scope>
            <scope name="offline_access">offline_access</scope>
          </scopes>
        </processor>
      </owin.identityProviders>
      <owin.cookieAuthentication.signedIn>
        <processor resolve="true" type="ShrikantTest.Feature.IdentityLogin.Pipelines.IdentityCheckProcessor, ShrikantTest.Feature.IdentityLogin"
                   patch:after="processor[@type='Sitecore.Analytics.Pipelines.StartTracking.ProcessItem, Sitecore.Analytics']" />
      </owin.cookieAuthentication.signedIn>
    </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="Site1" type="Sitecore.Owin.Authentication.Collections.IdentityProvidersPerSitesMapEntry, Sitecore.Owin.Authentication" resolve="true">
          <sites hint="list">
            <site>Site1</site>
          </sites>

          <identityProviders hint="list:AddIdentityProvider">
            <identityProvider ref="federatedAuthentication/identityProviders/identityProvider[@id='Site1IDP']" />
          </identityProviders>

          <externalUserBuilder type="Sitecore.Owin.Authentication.Services.DefaultExternalUserBuilder, Sitecore.Owin.Authentication" resolve="true">
            <patch:attribute name="type">ShrikantTest.Feature.IdentityLogin.Pipelines.CustomUserBuilder, ShrikantTest.Feature.IdentityLogin</patch:attribute>
            <param desc="isPersistentUser">false</param>
          </externalUserBuilder>
        </mapEntry>
      </identityProvidersPerSites>

      <identityProviders hint="list:AddIdentityProvider">
        <identityProvider id="Site1IDP" type="Sitecore.Owin.Authentication.Configuration.DefaultIdentityProvider, Sitecore.Owin.Authentication">
          <param desc="name">$(id)</param>
          <param desc="domainManager" type="Sitecore.Abstractions.BaseDomainManager" resolve="true" />
          <domain>extranet</domain>
          <caption>Sign In</caption>
          <icon></icon>
          <enabled>true</enabled>
          <triggerExternalSignOut>true</triggerExternalSignOut>
          <transformations hint="list:AddTransformation">
            <transformation name="Idp Claim" ref="federatedAuthentication/sharedTransformations/transformation[@name='set idp claim']" />
            <transformation name="Name Identifier Claim" type="Sitecore.Owin.Authentication.Services.DefaultTransformation, Sitecore.Owin.Authentication">
              <sources hint="raw:AddSource">
                <claim name="sub" />
              </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>
    </federatedAuthentication>
</sitecore>
</configuration>
	   
 

The configuration is straight forward. You can compare it exactly similar to Sitecore implementation.

Notes: The implementation might differ for your implementation but it will be similar processors which you can override according to your needs.

I Hope you would have like the blog. Keep learning and Happy Sitecoring.

Comments

Popular posts from this blog

Automate RSS Feed to Sitecore XM Cloud: Logic App, Next.js API & Authoring API Integration

Create and Fetch Content From Sitecore Content Hub One using GraphQL and React

Sitecore XM Cloud Form Integration with Azure Function as Webhook