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
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" },
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.
Configure IdentityServer4 for custom end session Logic
If you want more control over how and what back-channel clients receive a request, you can implement the IEndSessionRequestValidator interface when using IdentityServer4. The GetClientEndSessionUrlsAsync method could be edited to change the required clients which will be called after a logout event.
protected virtual async Task<(IEnumerable<string> frontChannel, IEnumerable<BackChannelLogoutModel> backChannel)> GetClientEndSessionUrlsAsync(EndSession endSession) { var frontChannelUrls = new List<string>(); var backChannelLogouts = new List<BackChannelLogoutModel>(); List<string> backchannelLogouts = new List<string> { "mvc.hybrid.backchannel", "mvc.hybrid.backchanneltwo" }; foreach (var clientId in backchannelLogouts) {
If the IEndSessionRequestValidator is implemented, this needs to be added to the ASP.NET Core IoC.
services.AddTransient<IEndSessionRequestValidator, MyEndSessionRequestValidator>();
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/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
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