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 withsitecore.publishingService.api
scope present in claimsadd a global
AuthorizeFilter
to all API endpoints with defined previouslyAuthorizationPolicy
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
toContent
Copy to Output Directory
toCopy 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 ServerCachedAuthorizationService
- service responsible for fetching the token fromMemoryCache
. We are going to use Decorator design pattern and decorateAuthorizationService
usingScrutor
packageMemoryCache
- service coming fromMicrosoft.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: