Securing Sitecore Publishing Service using Sitecore ID Server

Securing Sitecore Publishing Service using Sitecore ID Server

Use Sitecore Identity Server and OAuth Client Credentials Flow to protect Sitecore Publishing Service Host API endpoints from unauthorized access.

In this article we are going to dive into Sitecore Publishing Service and apply extra safety mechanism preventing unauthorized users from triggering a publishing operation. First, let’s briefly describe what Sitecore Publishing Service is and how publishing operations are triggered.

Sitecore Publishing Service is opt-in component of Sitecore XM/XP platform responsible for high-performance publishing. It is consisted of two elements:

  • Publishing Service Sitecore Host application that is handling the publishing operations. It is based on .NET and hosted separately than Sitecore XM/XP app.

  • Sitecore Publishing Module - a Sitecore XM/XP module that overrides default publishing behaviour and transfers information about requested publishing operations to Sitecore Host application. It contains also various UI additions such as Publishing Dashboard.

Whenever a publishing operation is triggered programmatically, for example by using PublishManager class, a publishing job is created and request is sent to Publishing Service Host API endpoint for further processing.

The API is documented by Swagger - to view it, launch Publishing Service Host app in development mode. As described in official documentation, run dotnet Sitecore.Framework.Publishing.Host.dll --environment development in the root of the Host app and enter https://localhost:5001/swagger/index.html. You should see then all available endpoints.

Other way of triggering a publishing operation is using Publish Item or Publish Site buttons using Sitecore UI. In this case a XHR request is sent to Sitecore Services Client API endpoint responsible for creation of new publishing job. Mentioned API endpoint is brought by Sitecore Publishing Module and is part of Sitecore.Publishing.Service.Client.Services DLL and Controllers namespace. There are few controllers defined and the PublishingJobController is the one that indirectly creates new publishing job.

Notice that each controller has [Authorize] attribute resulting denying unauthorized access to the API resource. Any attempt to send request to the API without authentication cookie will end-up with 401 response.

Let’s go back now to Publishing Service Host API mentioned earlier. That Publishing Service Host API is not secured by any form of authentication. The best practice here is that communication should be limited only to requests coming from Sitecore instance where Sitecore Publishing Module was installed. Generally speaking it should have local address allocated or proper IP whitelisting applied. If this is not in place then publishing operation can be triggered by crafting a request with required metadata.

I was exploring an option to add another line of defence here and secure Publishing Service Host API endpoints with help of Sitecore Identity Server. As Sitecore Identity Server server is full-blown token service engine based on OAuth 2.0 and OpenID Connect, we can try using Client Credentials Flow to implement machine to machine authentication. As you will see, thanks to the fact Sitecore is built with extensibility in mind, it will be pretty easy.

Defining new client in Sitecore Identity Server

First, we need to set a new client in Sitecore ID Server configuration, by modifying for example <id_server_hostname>\Config\production\Sitecore.IdentityServer.Host.xml file. Let’s add below XML snipped into <clients> section. It defines new client ID, name, secret, client credential flow grant type, token lifetime and allowed scope.

<SitecorePublishingService>
    <ClientId>SitecorePublishingService</ClientId>
    <ClientName>SitecorePublishingService</ClientName>
    <AccessTokenLifetimeInSeconds>3600</AccessTokenLifetimeInSeconds>
    <IdentityTokenLifetimeInSeconds>3600</IdentityTokenLifetimeInSeconds>
    <AllowedGrantTypes>
        <ClientCredentials>client_credentials</ClientCredentials>
    </AllowedGrantTypes>
    <AllowedScopes>
        <PublishingServiceApi>sitecore.publishingService.api</PublishingServiceApi>
    </AllowedScopes>
    <ClientSecrets>
        <ClientSecret>secret</ClientSecret>
    </ClientSecrets>
</SitecorePublishingService>

The client will be allowed to use dedicated sitecore.publishingService.api API scope. Let’s define mentioned new scope in the <IdentityServer> section.

<ApiScopes>
    <SitecorePublishingServiceApi>
        <Name>sitecore.publishingService.api</Name>
        <DisplayName>Sitecore Publishing Service API Scope</DisplayName>
    </SitecorePublishingServiceApi>
</ApiScopes>

Adding authentication to Publishing Host API

Next step is to enable authentication in Publishing Host App API. To do that we will need to create a Sitecore Host Plugin.

Sitecore Host Plugin is a standard established by Sitecore for developing/extending Sitecore related applications based on .NET (such as Identity Server, Publishing Service). Please find more details about it in official Sitecore Host Plugin documentation.

Entry point of every Sitecore Host Plugin is ConfigureSitecore class. It contains ConfigureServices(IServiceCollection services) method which can be used to manage service collection in DI container. Other method is Configure(IApplicationBuilder app) where we can enable various feature of the framework.

In order to create Sitecore Host Plugin, create a .NET compliant class library and add below dependency using Package Manager.

Install-Package Microsoft.AspNetCore.Authentication.JwtBearer -Version 6.0.36

Next step is to add ConfigureSitecore.cs class with the code as below which enables OAuth 2.0 Client Credentials Flow.

The ConfigureServices method:

  • adds authentication using JWT Bearer Token. Here we define URL of the Identity Authority which will be validating the token - Sitecore Identity Server

  • define AuthorizationPolicy that accepts only authenticated users with sitecore.publishingService.api scope present in claims

  • add a global AuthorizeFilter to all API endpoints with defined previously AuthorizationPolicy

The Configure method utilizes UseAuthentication extension method to enable authentication in the Publishing Service Host App.

namespace SitecoreGroove.Plugin.PublishingService.Authorization
{
    using Microsoft.AspNetCore.Authentication.JwtBearer;
    using Microsoft.AspNetCore.Authorization;
    using Microsoft.AspNetCore.Builder;
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.AspNetCore.Mvc.Authorization;
    using Microsoft.Extensions.DependencyInjection;

    public sealed class ConfigureSitecore
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
             .AddJwtBearer(options =>
             {
                 options.Authority = "https://<id_server_hostname>";
                 options.TokenValidationParameters.ValidateAudience = false;
             });

            AuthorizationPolicy policy = new AuthorizationPolicyBuilder()
            .RequireAuthenticatedUser()
                .RequireClaim("scope", new[] { "sitecore.publishingService.api" } )
                .Build();

            services.Configure<MvcOptions>(x => x.Filters.Add(new AuthorizeFilter(policy)));
        }

        public void Configure(IApplicationBuilder app)
        {
            app.UseAuthentication();
        }
    }
}

Another step is creation of Sitecore Host Plugin manifest file. Without it, the Publishing Service Host wouldn’t load our custom plugin. Lets create Sitecore.Plugin.manifest in sitecore\SitecoreGroove.Plugin.PublishingService.Authorization folder and set properties:

  • Build Action to Content

  • Copy to Output Directory to Copy always

Inside the manifest file, we define plugin and assembly name. Other tags we can leave with default or empty values. This plugin does not have any dependency on other plugin.

<?xml version="1.0" encoding="utf-8"?>
<SitecorePlugin PluginName="SitecoreGroove.Plugin.PublishingService.Authorization" AssemblyName="SitecoreGroove.Plugin.PublishingService.Authorization" Version="1.0.0">
    <Dependencies>
    </Dependencies>
    <Tags>
        <Sitecore>Sitecore</Sitecore>
    </Tags>
    <EnvironmentVariablePrefixes />
</SitecorePlugin>

When we leave everything in such shape and try to run Publishing Dashboard or to publish something, then we will get an error message informing that the Publishing Service is not running. In the logs we will see errors regarding Publishing Service Host API responding with 401 Unauthorized.

In order to make it working we need to extend Publishing Service Module to include valid JWT Bearer Token in all requests to Publishing Service Host App.

Extending Publishing Service Module to use JWT Bearer Token

Let’s have a look into Sitecore.Publishing.Service DLL and find out what HttpClient is used by Publishing Service Module to communicate with Publishing Host API. In PublishConfigurator we can see that AddRefitHttpClient extension method is used to register named HttpClient called refitClient.

Our goal will be to reconfigure refitClient to use custom DelegatingHandler - thanks to it we will be able to add extra logic to execute every time before the request is sent. It will be responsible for fetching, caching and including Bearer Token in requests to Publishing Service Host API.

First step is to create a .NET Framework Wep Application project and include below dependencies.

Install-Package Microsoft.Extensions.Caching.Memory -Version 6.0.3
Install-Package Microsoft.Extensions.Http -Version 3.1.9
Install-Package IdentityModel -Version 3.10.10
Install-Package Scrutor -Version 4.2.2
Install-Package Sitecore.Kernel -Version 10.4.0 -Source https://sitecore.myget.org/F/sc-packages/api/v3/index.json

Once done we need to make sure that Scrutor, Microsoft.Extensions.Caching.Memory and Microsoft.Extensions.Caching.Abstractions will be included in web deploy package. They need to have Copy Local property set to true. All other refferenced assembiles should have this seeting set to false as we don’t want to override Sitecore DLLs.

Next step is to modify web.config properties to not include in web deploy package as well. Let’s change Build Action to None and Copy to Output Directory to Do not copy.

Now we need to create a Sitecore Dependency Injection configurator class as below. We are going to register custom AuthorizationHandler which in fact is a DelegatingHandler. It will be added to the existing configuration of refitClient. We need to also register dependencies of AuthorizationHandler which are:

  • AuthorizationService - service responsible for fetching the token from Sitecore Identity Server

  • CachedAuthorizationService - service responsible for fetching the token from MemoryCache. We are going to use Decorator design pattern and decorate AuthorizationService using Scrutor package

  • MemoryCache - service coming from Microsoft.Extensions.Caching.Memory providing caching facility

using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Options;
using Sitecore.DependencyInjection;
using SitecoreGroove.Feature.PublishingServiceModule.Handlers;
using SitecoreGroove.Feature.PublishingServiceModule.Services;

namespace SitecoreGroove.Feature.PublishingServiceModule
{
    public class ServiceConfigurator : IServicesConfigurator
    {
        public virtual void Configure(IServiceCollection serviceCollection)
        {
            serviceCollection.AddTransient<AuthorizationHandler>();
            serviceCollection.AddSingleton<IMemoryCache, MemoryCache>();
            serviceCollection.AddSingleton<IAuthorizationService, AuthorizationService>();
            serviceCollection.Decorate<IAuthorizationService, CachedAuthorizationService>();

            serviceCollection.AddTransient<IConfigureOptions<HttpClientFactoryOptions>>(serviceProvider => new ConfigureNamedOptions<HttpClientFactoryOptions>("refitClient", options =>
            {
                options.HttpMessageHandlerBuilderActions.Add(builder =>
                {
                    builder.AdditionalHandlers.Add(serviceProvider.GetRequiredService<AuthorizationHandler>());
                });
            }));
        }
    }
}

We need to register new ServiceConfigurator in a config patch file. Let’s add to the project App_Config\Include\Feature\SitecoreGroove.Feature.PublishingServiceModule.config file with below content. It will also include SitecorePublishingService client ID and secret settings.

<?xml version="1.0"?>
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
    <sitecore>
        <settings>
            <setting name="PublishingService.Client.Id" value="SitecorePublishingService" />
            <setting name="PublishingService.Client.Secret" value="secret" />
        </settings>
        <services>
            <configurator type="SitecoreGroove.Feature.PublishingServiceModule.ServiceConfigurator, SitecoreGroove.Feature.PublishingServiceModule"
            patch:after="processor[@type='Sitecore.Publishing.Service.PublishingServiceConfigurator, Sitecore.Publishing.Service']" />
        </services>
    </sitecore>
</configuration>

Lets implement now AuthorizationHandler and put it into Handlers folder. We will override SendAsync method of refitClient HttpClient and include the part that will first get the Bearer Token from IAuthorizationService and include it in Authorization request header. Finally it will send the request.

using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using IdentityModel.Client;
using SitecoreGroove.Feature.PublishingServiceModule.Services;

namespace SitecoreGroove.Feature.PublishingServiceModule.Handlers
{
    public class AuthorizationHandler : DelegatingHandler
    {
        private readonly IAuthorizationService _authorizationService;

        public AuthorizationHandler(IAuthorizationService authorizationService)
        {
            _authorizationService = authorizationService;
        }

        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            TokenResponse token = await _authorizationService.GetTokenResponseAsync();

            request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken);

            return await base.SendAsync(request, cancellationToken);
        }
    }
}

The IAuthorizationService is an interface defining GetTokenResponseAsync() method. It is implemented by two services: AuthorizationService and CachedAuthorizationService.

using System.Threading.Tasks;
using IdentityModel.Client;

namespace SitecoreGroove.Feature.PublishingServiceModule.Services
{
    public interface IAuthorizationService
    {
        Task<TokenResponse> GetTokenResponseAsync();
    }
}

The AuthorizationService is responsible for getting the token from Sitecore Identity Server Token endpoint. The URL is taken from standard setting coming from default Content Management config files. Notice that we use here also setting items from the SitecoreGroove.Feature.PublishingServiceModule.config file regarding client ID and secret.

using System.Net.Http;
using System.Threading.Tasks;
using IdentityModel.Client;
using Sitecore.Abstractions;

namespace SitecoreGroove.Feature.PublishingServiceModule.Services
{
    public class AuthorizationService : IAuthorizationService
    {
        private readonly BaseLog _logger;
        private readonly BaseSettings _settings;
        private readonly IHttpClientFactory _httpClientFactory;

        public AuthorizationService(BaseSettings settings, BaseLog logger, IHttpClientFactory httpClientFactory)
        {
            _settings = settings;
            _httpClientFactory = httpClientFactory;
            _logger = logger;
        }

        public async Task<TokenResponse> GetTokenResponseAsync()
        {
            using (HttpClient client = _httpClientFactory.CreateClient())
            {
                TokenResponse tokenResponse = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest
                {
                    Address = $"{_settings.GetSetting("FederatedAuthentication.IdentityServer.Authority")}/connect/token",
                    ClientId = _settings.GetSetting("PublishingService.Client.Id"),
                    ClientSecret = _settings.GetSetting("PublishingService.Client.Secret")
                });

                if (tokenResponse.IsError)
                {
                    _logger.Error($"{nameof(AuthorizationService)} - Error while obtaining token - {tokenResponse.Error} - {tokenResponse.ErrorType} - {tokenResponse.ErrorDescription}", this);
                }

                return tokenResponse;
            }
        }
    }
}

Finally CachedAuthorizationService fetches or stores the TokenResponse using IMemoryCache. Thanks to caching facility, the Publishing Service Module won’t be fetching the token from Identity Server for every publishing request. It will reuse cached one until it expires. Whenever there will be a connection issue with Identity Server or it will be down, then we will use 1 second caching TTL.

using System;
using System.Threading.Tasks;
using IdentityModel.Client;
using Microsoft.Extensions.Caching.Memory;

namespace SitecoreGroove.Feature.PublishingServiceModule.Services
{
    public class CachedAuthorizationService : IAuthorizationService
    {
        private readonly IAuthorizationService _authorizationService;
        private readonly IMemoryCache _memoryCache;

        public CachedAuthorizationService(IAuthorizationService authorizationService, IMemoryCache memoryCache)
        {
            _authorizationService = authorizationService;
            _memoryCache = memoryCache;
        }

        public async Task<TokenResponse> GetTokenResponseAsync()
        {
            return await _memoryCache.GetOrCreateAsync("token", async cacheEntry =>
            {
                TokenResponse tokenResponse = await _authorizationService.GetTokenResponseAsync();

                cacheEntry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(tokenResponse.IsError ? 1 : tokenResponse.ExpiresIn);
                cacheEntry.SetValue(tokenResponse.AccessToken);

                return tokenResponse;
            });
        }
    }
}

Now, when we run the Publishing Dashboard, the error will be gone and Publishing Service will be fully operational.

Summary

In this article we’ve applied extra safety mechanism based on OAuth 2.0 Client Credentials Flow, preventing unauthorized users from triggering a publishing operation. In case local whitelisting policy is not in place and communication with Publishing Service Host is not limited to Sitecore Publishing Module, any unauthorized access will be blocked. Only the holder of valid JWT Bearer Token issued by Sitecore Identity Server will be allowed to access Publishing Service Host API resources.

Source code is available at: