OpenID Connect back-channel logout using Azure Redis Cache and IdentityServer4

This article shows how to implement an OpenID Connect back-channel logout, which uses Azure Redis cache so that the session logout will work with multi instance deployments.

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

Posts in this series:

History

2021-02-02 Updated .NET 5, IdentityServer4

Setting up the Azure Redis Cache

Before using the Azure Redis Cache in the application, this needs to be setup in Azure. Joonas Westlin has a nice blog about this. The Redis Azure FAQ link is also very good, which should help you decide the configuration which is correct for you.

Click “Create a Resource” and enter Redis Cache in the search input.

Then create the Redis Cache as required:

Creating the cache takes some time. Once finished, the connection string can be copied from the Access keys

Now that the Azure Redis is setup, you can add the cache to the ASP.NET Core application. In this example, the Microsoft.Extensions.Caching.Redis NuGet package is used to access and use the Azure Redis Cache. Add this to your project.

In the Startup class, add the distributed Redis cache using the AddDistributedRedisCache extension method from the NuGet package.

services.AddDistributedRedisCache(options =>
{
	options.Configuration = 
	  Configuration.GetConnectionString("RedisCacheConnection");
	options.InstanceName = "MvcHybridBackChannelInstance";
});

Add the Redis connection string to the app.settings. This example using the RedisCacheConnection. The values for this can be copied from the access keys tab in the Redis/Access keys menu which was created above.

The connection string should be added as a secret to the application, and not committed in the code.

"ConnectionStrings": {
    "RedisCacheConnection": "redis-connection-string" // user secrets
},

Using the Cache for the Back-Channel logout

The LogoutSessionManager class uses the Azure Redis cache to add or get the different logouts. The OpenID Connect back-channel specification defines how this logout works. The Secure Token Server, implemented using IdentityServer4, requests a logout URL which is handled in the client application.

The LogoutController class is used for this. If all the validation and the checks are ok, the class uses a singleton instance of LogoutSessionManager to manage the logouts for the client. The code used in this example, was created using the IdentityServer4.Samples.

The IDistributedCache is added in the constructor and saved as a read only field in the class.

private static readonly Object _lock = new Object();
private readonly ILogger<LogoutSessionManager> _logger;
private IDistributedCache _cache;

// Amount of time to check for old sessions. If this is to long, 
// the cache will increase, or if you have many user sessions, 
// this will increase to much.
private const int cacheExpirationInDays = 8;

public LogoutSessionManager(ILoggerFactory loggerFactory, IDistributedCache cache)
{
	_cache = cache;
	_logger = loggerFactory.CreateLogger<LogoutSessionManager>();
}

When a logout is initialized by a user, from an application, this request is sent to the OpenID Connect server. The server does the logout logic, and sends requests back to all applications that have the back-channel configured.

The LogoutController handles this request from the Secure Token Server, and adds a key pair to the Redis cache using the sid and the sub.

The Redis cache is shared between all instances of the client application and needs to be thread safe. Then all client instances can check if the user, application needs to be logged out.

public void Add(string sub, string sid)
{
	_logger.LogWarning($"Add a logout to the session: sub: {sub}, sid: {sid}");
	var options = new DistributedCacheEntryOptions()
          .SetSlidingExpiration(TimeSpan.FromDays(cacheExpirationInDays));

	lock (_lock)
	{
		var key = sub + sid;
		var logoutSession = _cache.GetString(key);
		if (logoutSession != null)
		{
			var session = JsonConvert.DeserializeObject<Session>(logoutSession);
		}
		else
		{
			var newSession = new Session { Sub = sub, Sid = sid };
			_cache.SetString(key, JsonConvert.SerializeObject(newSession), options);
		}
	}
}

The IsLoggedOutAsync method is used to check if a logout request exists for the application, user. This method uses the sid and sub values, to request the Redis value, if it exists.

public async Task<bool> IsLoggedOutAsync(string sub, string sid)
{
	var key = sub + sid;
	var matches = false;
	var logoutSession = await _cache.GetStringAsync(key);
	if (logoutSession != null)
	{
		var session = JsonConvert.DeserializeObject<Session>(logoutSession);
		matches = session.IsMatch(sub, sid);
		_logger.LogInformation($"Logout session exists T/F {matches} : {sub}, sid: {sid}");
	}

	return matches;
}

The method is used in the CookieEventHandler class in the ValidatePrincipal method to end the session if a logout request was found.

public override async Task ValidatePrincipal(CookieValidatePrincipalContext context)
{
	if (context.Principal.Identity.IsAuthenticated)
	{
		var sub = context.Principal.FindFirst("sub")?.Value;
		var sid = context.Principal.FindFirst("sid")?.Value;

		if (await LogoutSessions.IsLoggedOutAsync(sub, sid))
		{
			context.RejectPrincipal();
			await context.HttpContext.SignOutAsync(
                          CookieAuthenticationDefaults.AuthenticationScheme);
		}
	}
}

The CookieEventHandler was added in the Startup to the cookie configuration.

.AddCookie(options =>
{
	options.ExpireTimeSpan = TimeSpan.FromMinutes(60);
	options.Cookie.Name = "mvchybridbc";

	options.EventsType = typeof(CookieEventHandler);
})

Now Azure Redis cache is used to handle the back-channel logouts from the Secure Token Server.

The BackChannelLogoutSessionRequired and the BackChannelLogoutUri can be setup to send logout requests.

new Client
{
	ClientId = "mvc.hybrid.backchannel",
	ClientName = "MVC Hybrid (with BackChannel logout)",
	ClientUri = "http://identityserver.io",
	RequirePkce = false,
	ClientSecrets =
	{
		new Secret("secret".Sha256())
	},

	AllowedGrantTypes = GrantTypes.Hybrid,
	AllowAccessTokensViaBrowser = false,

	RedirectUris = { $"{mvcHybridBackchannelClientUrl}/signin-oidc" },
	BackChannelLogoutSessionRequired = true,
	BackChannelLogoutUri = $"{mvcHybridBackchannelClientUrl}/logout",
	PostLogoutRedirectUris = { $"{mvcHybridBackchannelClientUrl}/signout-callback-oidc" },

	AllowOfflineAccess = true,

	AllowedScopes =
	{
		IdentityServerConstants.StandardScopes.OpenId,
		IdentityServerConstants.StandardScopes.Profile,
		IdentityServerConstants.StandardScopes.Email
	}
},

Configure IdentityServer4 for custom logic

If you want more control over how and what back-channel clients receive a request, you can implement the IBackChannelLogoutService interface when using IdentityServer4.

Notes, Problems

One problem with this, is that all logouts are saved to the cache for n-days. If the logouts are removed to early, the logout will not work for a client application which is opened after this, or if the logout items are kept to long, the size of the Redis cache will be very large in size, and cost.

Links:

https://joonasw.net/view/redis-cache-session-store

https://docs.microsoft.com/en-us/aspnet/core/performance/caching/distributed?view=aspnetcore-2.2#distributed-redis-cache

https://docs.microsoft.com/en-us/azure/azure-cache-for-redis/

https://blogs.msdn.microsoft.com/luisdem/2016/09/06/azure-redis-cache-on-asp-net-core/

https://openid.net/specs/openid-connect-backchannel-1_0.html

http://docs.identityserver.io/en/release/topics/signout.html

View at Medium.com

View at Medium.com

https://ldapwiki.com/wiki/OpenID%20Connect%20Back-Channel%20Logout

https://datatracker.ietf.org/meeting/97/materials/slides-97-secevent-oidc-logout-01

https://docs.microsoft.com/en-us/aspnet/core/fundamentals/app-state?view=aspnetcore-2.2

https://docs.microsoft.com/en-us/azure/azure-cache-for-redis/cache-dotnet-core-quickstart

12 comments

  1. […] OpenID Connect back-channel logout using Azure Redis Cache and IdentityServer4 (Damien Bowden) […]

  2. Why not call context.HttpContext.SignOutAsync after you add the sign out token?

    1. The cookie is stored on the client, ie in the browser. So the reject session cannot be called until the client requests something from the server. With the next HTTP request, the session can be removed.

      Greetings Damien

      1. supertunix · ·

        According to IS4 docs “IdentityServer will automatically use this service when your logout page removes the user’s authentication cookie via a call to HttpContext.SignOutAsync. ” so if you don’t call SignOut, you have to wait for some client to happened to make a request to IS4.

      2. When you logout of ID4 or any client sends an endsession to the STS, the STS sends the backchannel logout to all clients which have a backchannel client configured. This is sent without delay. The client handles this then. How the client handles this, it the client business once the request is validated.

        So I save this logout to cache on the client server so that any future requests to this client (not STS) will logout. The cookie is on the client side.

        Hope this helps

        Greetings Damien

      3. supertunix · ·

        NVM kind of face-palm moment but I see now this is all for the RP not the OP. I am still confused how the OP keeps track of what sites I am logged into.

  3. supertunix · · Reply

    Why not call SignOut after you add the logout cache item as well?

    1. Like previous answer, you need to wait until the client sends the cookie

  4. Darren Schwarz · · Reply

    Hi Damien, First thank you! your example really helped fast track my understanding. One question I have relates to the expectation of an nonce. In the spec it suggests not to use a nonce https://openid.net/specs/openid-connect-backchannel-1_0.html#LogoutToken, to your knowledge is that the case?

    1. Hi Darren

      yes, I use the specs conform implementation which prohibites this.

      Greetings Damien

  5. Am I correct in understanding we only start calling back channel clients when a client attempts to invoke an IS4 endpoint? For example, if someone hits the Logout endpoint and provides a logout token, that will set the stage for back channel clients to be notified but they WONT be unless someone tries to invoke an IS4 endpoint?

    1. When you logout of ID4 or any client sends an endsession to the STS, the STS sends the backchannel logout to all clients which have a backchannel client configured. This is sent without delay. The client handles this then. How the client handles this, it the client business once the request is validated.

      So I save this logout to cache on the client server so that any future requests to this client (not STS) will logout. The cookie is on the client side.

      So you will recieve the notification from the STS to all client backends without delay. You validate this per RFC spec. Then you can do anything you want with this info. Maybe you have a better way to kill the sesison for this client and user.

      Hope this helps

      Greetings Damien

Leave a comment

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