Content Delivery API and Custom Authorization

Episerver Content Delivery API 2.x allows us to customize the authorization flow. Here's an example of how you can get and validate an access token using an external service and avoid all that similar stuff that bloated your Epi app when using 1.x.

OpenIddict, "the OpenID Connect server you'll be addicted to", seemed like a good fit for this exercise.

Clone or download the openiddict-samples and navigate to the samples\ClientCredentialsFlow\ folder.

In AuthorizationServer\Startup.cs, where it seems like a good place, add:

options.EnableIntrospectionEndpoint("/connect/introspect");

Same file in InitializeAsync(), rename ClientId from "console" to "resource_server". Also do this for ["client_id"] in the console ClientApp where you also change the API URL to your Alloy install URL and a CD API endpoint, for example http://localhost:54897/api/episerver/v2.0/site, in GetResourceAsync(). Later we then can use ClientApp when testing the Epi API.

Then setup a new Alloy site with Find and get the following packages installed.

IdentityModel
EPiServer.ContentDeliveryApi.Cms
EPiServer.ContentDeliveryApi.Core
EPiServer.ContentDeliveryApi.Search

For this PoC we use the Alloy default AspNetIdentity setup. In a real case it's more likely to have OpenID Connect for the regular authentication as well.

I add the AsyncHelper class to get some help.

Then initialize some stuff.

using EPiServer.ContentApi.Cms;
using EPiServer.ContentApi.Core.Configuration;
using EPiServer.Framework;
using EPiServer.Framework.Initialization;
using EPiServer.ServiceLocation;

namespace EpiserverSite.Business.Initialization
{
  [InitializableModule]
  [ModuleDependency(typeof(ContentApiCmsInitialization))]
  public class ExtendedContentApiCmsInitialization : IConfigurableModule
  {
    public void Initialize(InitializationEngine context)
    {
    }

    public void Uninitialize(InitializationEngine context)
    {
    }

    public void ConfigureContainer(ServiceConfigurationContext context)
    {
      context.Services.Configure<ContentApiConfiguration>(config =>
      {
        config
          .Default()
          .SetSiteDefinitionApiEnabled(true)
          .SetMultiSiteFilteringEnabled(true);
      });
    }
  }
}

The appropriate place to check the token is the Authorize() method of the ContentApiAuthorizationService so I need to override it like this.

using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Web;
using System.Web.Http.Controllers;
using EPiServer.ContentApi.Core.Configuration;
using EPiServer.ContentApi.Core.Internal;
using EPiServer.ContentApi.Core.Security;
using EPiServer.Security;
using IdentityModel.Client;

namespace EpiserverSite.Business.Security
{
  public class CustomContentApiAuthorizationService : ContentApiAuthorizationService
  {
    private readonly IUserImpersonation userImpersonation;

    public CustomContentApiAuthorizationService(
       ContentApiConfiguration apiConfig,
       RoleService roleService,
       UserService userService,
       ISecurityPrincipal principalAccessor,
       IUserImpersonation userImpersonation)
         : base(apiConfig, roleService, userService, principalAccessor)
    {
      this.userImpersonation = userImpersonation;
    }

    public override Tuple<HttpStatusCode, string> Authorize(HttpActionContext actionContext)
    {
      // Check for an authorization header
      string accessToken = null;

      if (actionContext.Request.Headers.TryGetValues("Authorization", out var values) && values.Any())
      {
        accessToken = values.First().Replace("Bearer ", string.Empty);
      }

      if (string.IsNullOrWhiteSpace(accessToken))
      {
        return new Tuple<HttpStatusCode, string>(HttpStatusCode.Forbidden, string.Empty);
      }

      // Call the OpenIddict server
      using (var client = new HttpClient())
      {
        client.Timeout = TimeSpan.FromSeconds(10);

        var response = AsyncHelper.RunSync(() => client.IntrospectTokenAsync(new TokenIntrospectionRequest
        {
          Address = "http://localhost:52698/connect/introspect",
          ClientId = "resource_server",
          ClientSecret = "388D45FA-B36B-4988-BA59-B187D329C207",
          Token = accessToken
        }));

        if (response.IsError || !response.IsActive)
        {
          return new Tuple<HttpStatusCode, string>(HttpStatusCode.Forbidden, string.Empty);
        }
      }

      // To get somewhere "admin" is a user I know exist in the site
      // In real world you would probably get the username from response.Claims
      actionContext.RequestContext.Principal = this.userImpersonation.CreatePrincipal("admin");
      Thread.CurrentPrincipal = actionContext.RequestContext.Principal;

      if (HttpContext.Current != null)
      {
        HttpContext.Current.User = actionContext.RequestContext.Principal;
      }

      return new Tuple<HttpStatusCode, string>(HttpStatusCode.OK, string.Empty);
    }
  }
}

To use this new thing I add it in Alloy's DependencyResolverInitialization.cs so that it looks like this.

context.Services.AddTransient<IContentRenderer, ErrorHandlingContentRenderer>()
  .AddTransient<ContentAreaRenderer, AlloyContentAreaRenderer>()
  .AddTransient<ContentApiAuthorizationService, CustomContentApiAuthorizationService>();

Now I can just go to samples\ClientCredentialsFlow and run the RunDemo Powershell script. This starts a build and starts the sample apps.

If things are good the ClientApp console program should get you a token, pass it, get it checked and get a response from the Epi site's Content Delivery API.

Screenshot showing a console app run with an access token and an authorized API call

Some alternatives would be to create a hash to use as cache key and cache the introspection result for a little while or, just to validate the token to see that it's from the right place and avoid the introspection outbound HTTP call.

Published and tagged with these categories: Episerver, Development, ASP.NET