Creating specific themes for OIDC clients using razor views with IdentityServer4

This post shows how to use specific themes in an ASPNET Core STS application using IdentityServer4. For each OpenId Connect (OIDC) client, a separate theme is used. The theme is implemented using Razor, based on the examples, code from Ben Foster. Thanks for these. The themes can then be customized as required.

Code: https://github.com/damienbod/AspNetCoreIdentityServer4Persistence

Setup

The applications are setup using 2 OIDC Implicit Flow clients which get the tokens and login using a single IdentityServer4 application. The client id is sent which each authorize request. The client id is used to select, switch the theme.

An instance of the ClientSelector class is used per request to set, save the selected client id. The class is registered as a scoped instance.

namespace IdentityServerWithIdentitySQLite
{
    public class ClientSelector
    {
        public string SelectedClient = "";
    }
}

The ClientIdFilter Action Filter is used to read the client id from the authorize request and saves this to the ClientSelector instance of the request. The client id is read from the requesturl parameter.

using System;
using Microsoft.Extensions.Primitives;
using Microsoft.AspNetCore.WebUtilities;
using System.Linq;
using Microsoft.AspNetCore.Mvc.Filters;

namespace IdentityServerWithIdentitySQLite
{
    public class ClientIdFilter : IActionFilter
    {
        public ClientIdFilter(ClientSelector clientSelector)
        {
            _clientSelector = clientSelector;
        }

        public string Client_id = "none";
        private readonly ClientSelector _clientSelector;

        public void OnActionExecuted(ActionExecutedContext context)
        {
            var query = context.HttpContext.Request.Query;
            var exists = query.TryGetValue("client_id", out StringValues culture);

            if (!exists)
            {
                exists = query.TryGetValue("returnUrl", out StringValues requesturl);

                if (exists)
                {
                    var request = requesturl.ToArray()[0];
                    Uri uri = new Uri("http://faketopreventexception" + request);
                    var query1 = QueryHelpers.ParseQuery(uri.Query);
                    var client_id = query1.FirstOrDefault(t => t.Key == "client_id").Value;

                    _clientSelector.SelectedClient = client_id.ToString();
                }
            }
        }

        public void OnActionExecuting(ActionExecutingContext context)
        {
            
        }
    }
}

Now that we have a ClientSelector instance which can be injected into the different views as required, we also want to use different razor templates for each theme.

The IViewLocationExpander interface is implemented and sets the locations for the different themes. For a request, the client_id is read from the authorize request. For a logout, the client_id is not available in the URL. The selectedClient is set in the logout action method, and this can be read then when rendering the views.

using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Primitives;
using System;
using System.Collections.Generic;
using System.Linq;

public class ClientViewLocationExpander : IViewLocationExpander
{
    private const string THEME_KEY = "theme";

    public void PopulateValues(ViewLocationExpanderContext context)
    {
        var query = context.ActionContext.HttpContext.Request.Query;
        var exists = query.TryGetValue("client_id", out StringValues culture);

        if (!exists)
        {
            exists = query.TryGetValue("returnUrl", out StringValues requesturl);

            if (exists)
            {
                var request = requesturl.ToArray()[0];
                Uri uri = new Uri("http://faketopreventexception" + request);
                var query1 = QueryHelpers.ParseQuery(uri.Query);
                var client_id = query1.FirstOrDefault(t => t.Key == "client_id").Value;

                context.Values[THEME_KEY] = client_id.ToString();
            }
        }
    }

    public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable<string> viewLocations)
    {
        // add the themes to the view location if one of the theme layouts are required. 
        if (context.ViewName.Contains("_Layout") 
            && context.ActionContext.HttpContext.Request.Path.ToString().Contains("logout"))
        {
            string themeValue = context.ViewName.Replace("_Layout", "");
            context.Values[THEME_KEY] = themeValue;
        }

        string theme = null;
        if (context.Values.TryGetValue(THEME_KEY, out theme))
        {
            viewLocations = new[] {
                $"/Themes/{theme}/{{1}}/{{0}}.cshtml",
                $"/Themes/{theme}/Shared/{{0}}.cshtml",
            }
            .Concat(viewLocations);
        }

        return viewLocations;
    }
}

The logout method in the account controller sets the theme and opens the correct themed view.

public async Task<IActionResult> Logout(LogoutViewModel model)
{
	...
	
	// get context information (client name, post logout redirect URI and iframe for federated signout)
	var logout = await _interaction.GetLogoutContextAsync(model.LogoutId);

	var vm = new LoggedOutViewModel
	{
		PostLogoutRedirectUri = logout?.PostLogoutRedirectUri,
		ClientName = logout?.ClientId,
		SignOutIframeUrl = logout?.SignOutIFrameUrl
	};
	_clientSelector.SelectedClient = logout?.ClientId;
	await _persistedGrantService.RemoveAllGrantsAsync(subjectId, logout?.ClientId);
	return View($"~/Themes/{logout?.ClientId}/Account/LoggedOut.cshtml", vm);
}

In the startup class, the classes are registered with the IoC, and the ClientViewLocationExpander is added.

public void ConfigureServices(IServiceCollection services)
{
	...
	
	services.AddScoped<ClientIdFilter>();
	services.AddScoped<ClientSelector>();
	services.AddAuthentication();

	services.Configure<RazorViewEngineOptions>(options =>
	{
		options.ViewLocationExpanders.Add(new ClientViewLocationExpander());
	});

In the Views folder, all the default views are implemented like before. The _ViewStart.cshtml was changed to select the correct layout using the injected service _clientSelector.

@using System.Globalization
@using IdentityServerWithAspNetIdentity.Resources
@inject LocService SharedLocalizer
@inject IdentityServerWithIdentitySQLite.ClientSelector _clientSelector
@{
    Layout = $"_Layout{_clientSelector.SelectedClient}";
}

Then the layout from the corresponding theme for the client is used and can be styled, changed as required for each client. Each themed Razor template which uses other views, should call the themed view. For example the ClientOne theme _Layout Razor view uses the _LoginPartial themed cshtml and not the default one.

@await Html.PartialAsync("~/Themes/ClientOne/Shared/_LoginPartial.cshtml")

The required themed views can then be implemented as required.

Client One themed view:

Client Two themed view:

Logout themed view for Client Two:

Links:

http://benfoster.io/blog/asp-net-core-themes-and-multi-tenancy

http://docs.identityserver.io/en/release/

https://docs.microsoft.com/en-us/ef/core/

https://docs.microsoft.com/en-us/aspnet/core/

https://getmdl.io/started/

One comment

  1. […] Creating specific themes for OIDC clients using razor views with IdentityServer4 – Damien Bowden […]

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.