From f20173d9e1a40ac57f1c46c15e03c60d7c19c6f8 Mon Sep 17 00:00:00 2001 From: James Britton Date: Tue, 4 Aug 2026 16:21:07 +0100 Subject: [PATCH 1/8] pr: action comments from PR review, missing tests, superfluous tags on traces --- .../src/Stores/InMemory/InMemorySessionStore.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs b/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs index 2b86b5a0..85181f8e 100644 --- a/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs +++ b/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs @@ -4,8 +4,6 @@ #nullable enable using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using Open.IdentityServer.Models; From d5858daf413359bf4356446851802c4dfab9ec63 Mon Sep 17 00:00:00 2001 From: James Britton Date: Mon, 10 Aug 2026 09:59:43 +0100 Subject: [PATCH 2/8] feat: added settings for backchannel logout/user session coordination --- .../src/Entities/Client.cs | 2 +- .../Options/AuthenticationOptions.cs | 8 ++++- .../Options/IdentityServerOptions.cs | 32 +++++++++++-------- .../Options/ServerSideSessionsOptions.cs | 9 ++++++ src/Storage/src/Models/Client.cs | 11 +++---- 5 files changed, 40 insertions(+), 22 deletions(-) create mode 100644 src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs diff --git a/src/EntityFramework.Storage/src/Entities/Client.cs b/src/EntityFramework.Storage/src/Entities/Client.cs index a17828f5..97a0ff4d 100644 --- a/src/EntityFramework.Storage/src/Entities/Client.cs +++ b/src/EntityFramework.Storage/src/Entities/Client.cs @@ -65,11 +65,11 @@ public class Client public string UserCodeType { get; set; } public int DeviceCodeLifetime { get; set; } = 300; public bool NonEditable { get; set; } + public bool? CoordinateLifetimeWithUserSession { get; set; } //Unused Compatibility Properties public int? CibaLifetime { get; set; } public int? PollingInterval { get; set; } - public bool? CoordinateLifetimeWithUserSession { get; set; } public string InitiateLoginUri { get; set; } public TimeSpan DPoPClockSkew { get; set; } public int DPoPValidationMode { get; set; } diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/AuthenticationOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/AuthenticationOptions.cs index 0039185c..28d72c89 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/AuthenticationOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/AuthenticationOptions.cs @@ -1,7 +1,7 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. - using System; using Microsoft.AspNetCore.Http; @@ -60,4 +60,10 @@ public class AuthenticationOptions /// If set, will require frame-src CSP headers being emitting on the end session callback endpoint which renders iframes to clients for front-channel signout notification. /// public bool RequireCspFrameSrcForSignout { get; set; } = true; + + /// + /// If set, refresh token lifetimes will be tied to the users' session. This setting can be overridden at a client + /// level. + /// + public bool CoordinateClientLifetimesWithUserSession { get; set; } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs index 0df4a320..98b78952 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs @@ -2,7 +2,6 @@ // Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. - namespace Open.IdentityServer.Configuration; /// @@ -54,7 +53,7 @@ public class IdentityServerOptions /// /// The endpoints configuration. /// - public EndpointsOptions Endpoints { get; set; } = new EndpointsOptions(); + public EndpointsOptions Endpoints { get; set; } = new(); /// /// Gets or sets the discovery endpoint configuration. @@ -62,7 +61,7 @@ public class IdentityServerOptions /// /// The discovery endpoint configuration. /// - public DiscoveryOptions Discovery { get; set; } = new DiscoveryOptions(); + public DiscoveryOptions Discovery { get; set; } = new(); /// /// Gets or sets the authentication options. @@ -70,7 +69,7 @@ public class IdentityServerOptions /// /// The authentication options. /// - public AuthenticationOptions Authentication { get; set; } = new AuthenticationOptions(); + public AuthenticationOptions Authentication { get; set; } = new(); /// /// Gets or sets the events options. @@ -78,7 +77,7 @@ public class IdentityServerOptions /// /// The events options. /// - public EventsOptions Events { get; set; } = new EventsOptions(); + public EventsOptions Events { get; set; } = new(); /// /// Gets or sets the max input length restrictions. @@ -86,7 +85,7 @@ public class IdentityServerOptions /// /// The length restrictions. /// - public InputLengthRestrictions InputLengthRestrictions { get; set; } = new InputLengthRestrictions(); + public InputLengthRestrictions InputLengthRestrictions { get; set; } = new(); /// /// Gets or sets the options for the user interaction. @@ -94,7 +93,7 @@ public class IdentityServerOptions /// /// The user interaction options. /// - public UserInteractionOptions UserInteraction { get; set; } = new UserInteractionOptions(); + public UserInteractionOptions UserInteraction { get; set; } = new(); /// /// Gets or sets the caching options. @@ -102,7 +101,7 @@ public class IdentityServerOptions /// /// The caching options. /// - public CachingOptions Caching { get; set; } = new CachingOptions(); + public CachingOptions Caching { get; set; } = new(); /// /// Gets or sets the cors options. @@ -110,35 +109,40 @@ public class IdentityServerOptions /// /// The cors options. /// - public CorsOptions Cors { get; set; } = new CorsOptions(); + public CorsOptions Cors { get; set; } = new(); /// /// Gets or sets the Content Security Policy options. /// - public CspOptions Csp { get; set; } = new CspOptions(); + public CspOptions Csp { get; set; } = new(); /// /// Gets or sets the validation options. /// - public ValidationOptions Validation { get; set; } = new ValidationOptions(); + public ValidationOptions Validation { get; set; } = new(); /// /// Gets or sets the device flow options. /// - public DeviceFlowOptions DeviceFlow { get; set; } = new DeviceFlowOptions(); + public DeviceFlowOptions DeviceFlow { get; set; } = new(); /// /// Gets or sets the logging options /// - public LoggingOptions Logging { get; set; } = new LoggingOptions(); + public LoggingOptions Logging { get; set; } = new(); /// /// Gets or sets the mutual TLS options. /// - public MutualTlsOptions MutualTls { get; set; } = new MutualTlsOptions(); + public MutualTlsOptions MutualTls { get; set; } = new(); /// /// Gets or sets the enable authorise response issuer param option /// public bool EnableAuthorizeResponseIssuerParam { get; set; } = false; + + /// + /// Gets or sets the server-side session options + /// + public ServerSideSessionsOptions ServerSideSessions { get; set; } = new(); } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs new file mode 100644 index 00000000..40f41c3c --- /dev/null +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs @@ -0,0 +1,9 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +namespace Open.IdentityServer.Configuration; + +public class ServerSideSessionsOptions +{ + public bool ExpiredSessionsTriggerBackchannelLogout { get; set; } +} \ No newline at end of file diff --git a/src/Storage/src/Models/Client.cs b/src/Storage/src/Models/Client.cs index a9fe06d1..025e3695 100644 --- a/src/Storage/src/Models/Client.cs +++ b/src/Storage/src/Models/Client.cs @@ -431,8 +431,12 @@ IEnumerator IEnumerable.GetEnumerator() } } - //Unused Compatibility Properties + /// + /// Gets or sets the coordinate lifetime with the user session + /// + public bool? CoordinateLifetimeWithUserSession { get; set; } + //Unused Compatibility Properties /// /// Gets or sets CIBA lifetime (Unused, added for compatibility) /// @@ -443,11 +447,6 @@ IEnumerator IEnumerable.GetEnumerator() /// public int? PollingInterval { get; set; } - /// - /// Gets or sets coordinate lifetime with user session (Unused, added for compatibility) - /// - public bool? CoordinateLifetimeWithUserSession { get; set; } - /// /// Gets or sets initiate login URI (Unused, added for compatibility) /// From 64f66338eea438256eb5507bff7650dcbe6b4f0e Mon Sep 17 00:00:00 2001 From: James Britton Date: Mon, 10 Aug 2026 16:44:56 +0100 Subject: [PATCH 3/8] feat: added support for multiple client ids and types for persistent grant filters --- .../src/Stores/PersistedGrantStore.cs | 26 +++-- .../Stores/PersistedGrantStoreTests.cs | 53 ++++++++- .../InMemory/InMemoryPersistedGrantStore.cs | 14 ++- .../InMemoryPersistedGrantStoreTests.cs | 110 +++++++++++++++--- .../PersistedGrantFilterExtensions.cs | 11 +- .../src/Stores/PersistedGrantFilter.cs | 32 ++++- 6 files changed, 197 insertions(+), 49 deletions(-) diff --git a/src/EntityFramework.Storage/src/Stores/PersistedGrantStore.cs b/src/EntityFramework.Storage/src/Stores/PersistedGrantStore.cs index 53edc120..cb84c24e 100644 --- a/src/EntityFramework.Storage/src/Stores/PersistedGrantStore.cs +++ b/src/EntityFramework.Storage/src/Stores/PersistedGrantStore.cs @@ -172,37 +172,41 @@ private void AddFilterTags(ITrace trace, PersistedGrantFilter filter) { if (trace == null) return; - if (!String.IsNullOrWhiteSpace(filter.ClientId)) + var clientIds = filter.ClientIds.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + if (clientIds.Any()) { - trace.AddTag(TelemetryConstants.TagConstants.Client, filter.ClientId); + trace.AddTag(TelemetryConstants.TagConstants.Client, string.Join(",", filter.ClientIds)); } - if (!String.IsNullOrWhiteSpace(filter.SubjectId)) + if (!string.IsNullOrWhiteSpace(filter.SubjectId)) { trace.AddTag(TelemetryConstants.TagConstants.Subject, filter.SubjectId); } - if (!String.IsNullOrWhiteSpace(filter.Type)) + var types = filter.Types.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + if (types.Any()) { - trace.AddTag(TelemetryConstants.TagConstants.GrantType, filter.Type); + trace.AddTag(TelemetryConstants.TagConstants.GrantType, string.Join(",", filter.Types)); } } private IQueryable Filter(IQueryable query, PersistedGrantFilter filter) { - if (!String.IsNullOrWhiteSpace(filter.ClientId)) + var clientIds = filter.ClientIds.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + if (clientIds.Any()) { - query = query.Where(x => x.ClientId == filter.ClientId); + query = query.Where(x => clientIds.Contains(x.ClientId)); } - if (!String.IsNullOrWhiteSpace(filter.SessionId)) + if (!string.IsNullOrWhiteSpace(filter.SessionId)) { query = query.Where(x => x.SessionId == filter.SessionId); } - if (!String.IsNullOrWhiteSpace(filter.SubjectId)) + if (!string.IsNullOrWhiteSpace(filter.SubjectId)) { query = query.Where(x => x.SubjectId == filter.SubjectId); } - if (!String.IsNullOrWhiteSpace(filter.Type)) + var types = filter.Types.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + if (types.Any()) { - query = query.Where(x => x.Type == filter.Type); + query = query.Where(x => types.Contains(x.Type)); } return query; diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/PersistedGrantStoreTests.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/PersistedGrantStoreTests.cs index 6dd5eec4..71919bc1 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/Stores/PersistedGrantStoreTests.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/PersistedGrantStoreTests.cs @@ -187,6 +187,19 @@ public async Task GetAllAsync_Should_Filter(DbContextOptions.Create()); + + await store.RemoveAllAsync(new PersistedGrantFilter + { + ClientIds = ["c2", "c3"], + }); + context.PersistedGrants.Count().Should().Be(5); + } + + await PopulateDb(); + await using (var context = new PersistedGrantDbContext(options, StoreOptions)) + { + var store = new PersistedGrantStore(context, _telemetry, FakeLogger.Create()); + + await store.RemoveAllAsync(new PersistedGrantFilter + { + Types = ["t1", "t2"], + }); + context.PersistedGrants.Count().Should().Be(2); + } + + await PopulateDb(); + await using (var context = new PersistedGrantDbContext(options, StoreOptions)) + { + var store = new PersistedGrantStore(context, _telemetry, FakeLogger.Create()); + + await store.RemoveAllAsync(new PersistedGrantFilter + { + ClientIds = ["c1", "c3"], + Types = ["t2", "t3"], + }); + context.PersistedGrants.Count().Should().Be(7); + } + return; async Task PopulateDb() diff --git a/src/Open.IdentityServer/src/Stores/InMemory/InMemoryPersistedGrantStore.cs b/src/Open.IdentityServer/src/Stores/InMemory/InMemoryPersistedGrantStore.cs index 54da9d40..767be3cc 100644 --- a/src/Open.IdentityServer/src/Stores/InMemory/InMemoryPersistedGrantStore.cs +++ b/src/Open.IdentityServer/src/Stores/InMemory/InMemoryPersistedGrantStore.cs @@ -77,21 +77,23 @@ private IEnumerable Filter(PersistedGrantFilter filter) from item in _repository select item.Value; - if (!String.IsNullOrWhiteSpace(filter.ClientId)) + var clientIds = filter.ClientIds.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + if (clientIds.Any()) { - query = query.Where(x => x.ClientId == filter.ClientId); + query = query.Where(x => clientIds.Contains(x.ClientId)); } - if (!String.IsNullOrWhiteSpace(filter.SessionId)) + if (!string.IsNullOrWhiteSpace(filter.SessionId)) { query = query.Where(x => x.SessionId == filter.SessionId); } - if (!String.IsNullOrWhiteSpace(filter.SubjectId)) + if (!string.IsNullOrWhiteSpace(filter.SubjectId)) { query = query.Where(x => x.SubjectId == filter.SubjectId); } - if (!String.IsNullOrWhiteSpace(filter.Type)) + var types = filter.Types.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + if (types.Any()) { - query = query.Where(x => x.Type == filter.Type); + query = query.Where(x => types.Contains(x.Type)); } var items = query.ToArray().AsEnumerable(); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemoryPersistedGrantStoreTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemoryPersistedGrantStoreTests.cs index 9ffb4c2f..d87074e6 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemoryPersistedGrantStoreTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemoryPersistedGrantStoreTests.cs @@ -38,17 +38,14 @@ public async Task Store_should_persist_value() [Fact] public async Task GetAll_should_filter() { - await _subject.StoreAsync(new PersistedGrant() { Key = "key1", SubjectId = "sub1", ClientId = "client1", SessionId = "session1" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key2", SubjectId = "sub1", ClientId = "client2", SessionId = "session1" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key3", SubjectId = "sub1", ClientId = "client1", SessionId = "session2" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key4", SubjectId = "sub1", ClientId = "client3", SessionId = "session2" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key5", SubjectId = "sub1", ClientId = "client4", SessionId = "session3" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key6", SubjectId = "sub1", ClientId = "client4", SessionId = "session4" }); - - await _subject.StoreAsync(new PersistedGrant() { Key = "key7", SubjectId = "sub2", ClientId = "client4", SessionId = "session4" }); - - - + await _subject.StoreAsync(new PersistedGrant() { Key = "key1", SubjectId = "sub1", ClientId = "client1", SessionId = "session1", Type = "typeA"}); + await _subject.StoreAsync(new PersistedGrant() { Key = "key2", SubjectId = "sub1", ClientId = "client2", SessionId = "session1", Type = "typeB" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key3", SubjectId = "sub1", ClientId = "client1", SessionId = "session2", Type = "typeB" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key4", SubjectId = "sub1", ClientId = "client3", SessionId = "session2", Type = "typeA" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key5", SubjectId = "sub1", ClientId = "client4", SessionId = "session3", Type = "typeC" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key6", SubjectId = "sub1", ClientId = "client4", SessionId = "session4", Type = "typeA" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key7", SubjectId = "sub2", ClientId = "client4", SessionId = "session4", Type = "typeC" }); + (await _subject.GetAllAsync(new PersistedGrantFilter { SubjectId = "sub1" @@ -194,6 +191,25 @@ public async Task GetAll_should_filter() SessionId = "session5" })) .Select(x => x.Key).Should().BeEmpty(); + + (await _subject.GetAllAsync(new PersistedGrantFilter + { + ClientIds = ["client1", "client4"], + })) + .Select(x => x.Key).Should().BeEquivalentTo("key1", "key3", "key5", "key6", "key7"); + + (await _subject.GetAllAsync(new PersistedGrantFilter + { + Types = ["typeA", "typeC"], + })) + .Select(x => x.Key).Should().BeEquivalentTo("key1", "key4", "key5", "key6", "key7"); + + (await _subject.GetAllAsync(new PersistedGrantFilter + { + ClientIds = ["client2", "client3"], + Types = ["typeA", "typeB"], + })) + .Select(x => x.Key).Should().BeEquivalentTo("key2", "key4"); } [Fact] @@ -521,17 +537,73 @@ await _subject.RemoveAllAsync(new PersistedGrantFilter (await _subject.GetAsync("key6")).Should().NotBeNull(); (await _subject.GetAsync("key7")).Should().NotBeNull(); } + { + await Populate(); + await _subject.RemoveAllAsync(new PersistedGrantFilter + { + ClientIds = ["client1", "client2"], + }); + (await _subject.GetAsync("key1")).Should().BeNull(); + (await _subject.GetAsync("key2")).Should().BeNull(); + (await _subject.GetAsync("key3")).Should().BeNull(); + (await _subject.GetAsync("key4")).Should().NotBeNull(); + (await _subject.GetAsync("key5")).Should().NotBeNull(); + (await _subject.GetAsync("key6")).Should().NotBeNull(); + (await _subject.GetAsync("key7")).Should().NotBeNull(); + } + { + await Populate(); + await _subject.RemoveAllAsync(new PersistedGrantFilter + { + Type = "typeA", + }); + (await _subject.GetAsync("key1")).Should().BeNull(); + (await _subject.GetAsync("key2")).Should().NotBeNull(); + (await _subject.GetAsync("key3")).Should().NotBeNull(); + (await _subject.GetAsync("key4")).Should().BeNull(); + (await _subject.GetAsync("key5")).Should().NotBeNull(); + (await _subject.GetAsync("key6")).Should().NotBeNull(); + (await _subject.GetAsync("key7")).Should().BeNull(); + } + { + await Populate(); + await _subject.RemoveAllAsync(new PersistedGrantFilter + { + Types = ["typeB", "typeC"], + }); + (await _subject.GetAsync("key1")).Should().NotBeNull(); + (await _subject.GetAsync("key2")).Should().BeNull(); + (await _subject.GetAsync("key3")).Should().BeNull(); + (await _subject.GetAsync("key4")).Should().NotBeNull(); + (await _subject.GetAsync("key5")).Should().BeNull(); + (await _subject.GetAsync("key6")).Should().BeNull(); + (await _subject.GetAsync("key7")).Should().NotBeNull(); + } + { + await Populate(); + await _subject.RemoveAllAsync(new PersistedGrantFilter + { + ClientIds = ["client3", "client4"], + Types = ["typeA", "typeC"], + }); + (await _subject.GetAsync("key1")).Should().NotBeNull(); + (await _subject.GetAsync("key2")).Should().NotBeNull(); + (await _subject.GetAsync("key3")).Should().NotBeNull(); + (await _subject.GetAsync("key4")).Should().BeNull(); + (await _subject.GetAsync("key5")).Should().BeNull(); + (await _subject.GetAsync("key6")).Should().BeNull(); + (await _subject.GetAsync("key7")).Should().BeNull(); + } } private async Task Populate() { - await _subject.StoreAsync(new PersistedGrant() { Key = "key1", SubjectId = "sub1", ClientId = "client1", SessionId = "session1" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key2", SubjectId = "sub1", ClientId = "client2", SessionId = "session1" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key3", SubjectId = "sub1", ClientId = "client1", SessionId = "session2" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key4", SubjectId = "sub1", ClientId = "client3", SessionId = "session2" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key5", SubjectId = "sub1", ClientId = "client4", SessionId = "session3" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key6", SubjectId = "sub1", ClientId = "client4", SessionId = "session4" }); - - await _subject.StoreAsync(new PersistedGrant() { Key = "key7", SubjectId = "sub2", ClientId = "client4", SessionId = "session4" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key1", SubjectId = "sub1", ClientId = "client1", SessionId = "session1", Type = "typeA" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key2", SubjectId = "sub1", ClientId = "client2", SessionId = "session1", Type = "typeB" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key3", SubjectId = "sub1", ClientId = "client1", SessionId = "session2", Type = "typeB" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key4", SubjectId = "sub1", ClientId = "client3", SessionId = "session2", Type = "typeA" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key5", SubjectId = "sub1", ClientId = "client4", SessionId = "session3", Type = "typeC" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key6", SubjectId = "sub1", ClientId = "client4", SessionId = "session4", Type = "typeC" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key7", SubjectId = "sub2", ClientId = "client4", SessionId = "session4", Type = "typeA" }); } } \ No newline at end of file diff --git a/src/Storage/src/Extensions/PersistedGrantFilterExtensions.cs b/src/Storage/src/Extensions/PersistedGrantFilterExtensions.cs index 4bbcf539..85bcf478 100644 --- a/src/Storage/src/Extensions/PersistedGrantFilterExtensions.cs +++ b/src/Storage/src/Extensions/PersistedGrantFilterExtensions.cs @@ -4,6 +4,7 @@ using Open.IdentityServer.Stores; using System; +using System.Linq; namespace Open.IdentityServer.Extensions; @@ -18,12 +19,12 @@ public static class PersistedGrantFilterExtensions /// public static void Validate(this PersistedGrantFilter filter) { - if (filter == null) throw new ArgumentNullException(nameof(filter)); + ArgumentNullException.ThrowIfNull(filter); - if (String.IsNullOrWhiteSpace(filter.ClientId) && - String.IsNullOrWhiteSpace(filter.SessionId) && - String.IsNullOrWhiteSpace(filter.SubjectId) && - String.IsNullOrWhiteSpace(filter.Type)) + if (filter.ClientIds.Any(string.IsNullOrWhiteSpace) && + string.IsNullOrWhiteSpace(filter.SessionId) && + string.IsNullOrWhiteSpace(filter.SubjectId) && + filter.Types.Any(string.IsNullOrWhiteSpace)) { throw new ArgumentException("No filter values set.", nameof(filter)); } diff --git a/src/Storage/src/Stores/PersistedGrantFilter.cs b/src/Storage/src/Stores/PersistedGrantFilter.cs index 9bbbc51e..c52d42e0 100644 --- a/src/Storage/src/Stores/PersistedGrantFilter.cs +++ b/src/Storage/src/Stores/PersistedGrantFilter.cs @@ -1,6 +1,8 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. +using System.Linq; + namespace Open.IdentityServer.Stores; /// @@ -19,14 +21,32 @@ public class PersistedGrantFilter /// Session id used for the grant. /// public string SessionId { get; set; } - + /// - /// Client id the grant was issued to. + /// Client id the grant was issued to. For backwards compatibility. /// - public string ClientId { get; set; } - + public string ClientId + { + init => ClientIds = [value]; + get => ClientIds.FirstOrDefault(); + } + + /// + /// Client ids the grant was issued to. Multiple elements in array interpreted as a logic 'OR' for the client id property. + /// + public string[] ClientIds { get; set; } = []; + + /// + /// The type of grant. For backwards compatibility. + /// + public string Type + { + init => Types = [value]; + get => Types.FirstOrDefault(); + } + /// - /// The type of grant. + /// The type of grant. Multiple elements in array interpreted as a logic 'OR' for the type property. /// - public string Type { get; set; } + public string[] Types { get; set; } = []; } \ No newline at end of file From 74e6799d5d1803bdae620b8e12dcbae1d96ab40c Mon Sep 17 00:00:00 2001 From: James Britton Date: Tue, 11 Aug 2026 12:56:21 +0100 Subject: [PATCH 4/8] feat: implemented user session event service for handling logout and expiry --- .../Options/ServerSideSessionsOptions.cs | 6 + .../src/IdentityServerConstants.cs | 2 + .../Contexts/UserSessionEventContext.cs | 25 ++ .../DefaultUserSessionEventsService.cs | 106 ++++++ .../src/Services/IUserSessionEventsService.cs | 29 ++ .../DefaultUserSessionEventsServiceTests.cs | 343 ++++++++++++++++++ 6 files changed, 511 insertions(+) create mode 100644 src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs create mode 100644 src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs create mode 100644 src/Open.IdentityServer/src/Services/IUserSessionEventsService.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs index 40f41c3c..76b5f41e 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs @@ -3,7 +3,13 @@ namespace Open.IdentityServer.Configuration; +/// +/// +/// public class ServerSideSessionsOptions { + /// + /// + /// public bool ExpiredSessionsTriggerBackchannelLogout { get; set; } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/IdentityServerConstants.cs b/src/Open.IdentityServer/src/IdentityServerConstants.cs index 7d10fdfd..6a7dbe28 100644 --- a/src/Open.IdentityServer/src/IdentityServerConstants.cs +++ b/src/Open.IdentityServer/src/IdentityServerConstants.cs @@ -152,6 +152,8 @@ public static class PersistedGrantTypes public const string UserConsent = "user_consent"; public const string DeviceCode = "device_code"; public const string UserCode = "user_code"; + + public static readonly string[] PersistedGrantTokenTypes = [AuthorizationCode, ReferenceToken, RefreshToken]; } public static class UserCodeTypes diff --git a/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs b/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs new file mode 100644 index 00000000..23e49610 --- /dev/null +++ b/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs @@ -0,0 +1,25 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +namespace Open.IdentityServer.Models; + +/// +/// Provides the context necessary to handle user session events +/// +public class UserSessionEventContext +{ + /// + /// Subject identifier of the user of the session the event has been triggered for + /// + public string SubjectId { get; set; } + + /// + /// Session identifier of the session the event has been triggered for + /// + public string SessionId { get; set; } + + /// + /// ClientIds logged into with the user session + /// + public string[] ClientIds { get; set; } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs b/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs new file mode 100644 index 00000000..f0283a6c --- /dev/null +++ b/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs @@ -0,0 +1,106 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +#nullable enable + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Models; +using Open.IdentityServer.Stores; + +namespace Open.IdentityServer.Services.Default; + +/// +/// Default user session event handler for Open.IdentityServer +/// +/// client store +/// persisted grant store +/// back channel logout service +/// IdentityServer options +/// logger +public class DefaultUserSessionEventsService( + IClientStore clientStore, + IPersistedGrantStore persistedGrantStore, + IBackChannelLogoutService backChannelLogoutService, + IdentityServerOptions idsOptions, + ILogger logger) : IUserSessionEventsService +{ + /// + public async Task HandleUserSessionLogout(UserSessionEventContext sessionEventContext) + { + if (sessionEventContext.ClientIds.Length == 0) + { + return; + } + + await EndSessionForClients(sessionEventContext); + + await backChannelLogoutService.SendLogoutNotificationsAsync(new LogoutNotificationContext + { + SubjectId = sessionEventContext.SubjectId, + SessionId = sessionEventContext.SessionId, + ClientIds = sessionEventContext.ClientIds, + }); + } + + /// + public async Task HandleUserSessionExpiry(UserSessionEventContext sessionEventContext) + { + var clientToNotify = await EndSessionForClients(sessionEventContext); + + var backChannelClients = (idsOptions.ServerSideSessions.ExpiredSessionsTriggerBackchannelLogout + ? sessionEventContext.ClientIds + : clientToNotify ?? []).ToList(); + + if (backChannelClients.Count == 0) + { + return; + } + + await backChannelLogoutService.SendLogoutNotificationsAsync(new LogoutNotificationContext + { + SubjectId = sessionEventContext.SubjectId, + SessionId = sessionEventContext.SessionId, + ClientIds = backChannelClients, + }); + } + + private async Task EndSessionForClients(UserSessionEventContext sessionEventContext) + { + var clientIds = await ClientIdsToCoordinate(sessionEventContext).ToArrayAsync(); + + if (clientIds.Length == 0) + { + return null; + } + + await persistedGrantStore.RemoveAllAsync(new PersistedGrantFilter + { + SubjectId = sessionEventContext.SubjectId, + SessionId = sessionEventContext.SessionId, + ClientIds = sessionEventContext.ClientIds, + Types = IdentityServerConstants.PersistedGrantTypes.PersistedGrantTokenTypes + }); + + return clientIds; + } + + private bool ShouldCoordinate(Client client) => client.CoordinateLifetimeWithUserSession ?? + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession; + + private async IAsyncEnumerable ClientIdsToCoordinate(UserSessionEventContext sessionEventContext) + { + foreach (string clientId in sessionEventContext.ClientIds) + { + var client = await clientStore.FindClientByIdAsync(clientId); + + if (client != null && ShouldCoordinate(client)) + { + yield return client.ClientId; + } + } + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Services/IUserSessionEventsService.cs b/src/Open.IdentityServer/src/Services/IUserSessionEventsService.cs new file mode 100644 index 00000000..580fb9d2 --- /dev/null +++ b/src/Open.IdentityServer/src/Services/IUserSessionEventsService.cs @@ -0,0 +1,29 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +#nullable enable + +using System.Threading.Tasks; +using Open.IdentityServer.Models; + +namespace Open.IdentityServer.Services; + +/// +/// Service responsible handling user session events +/// +public interface IUserSessionEventsService +{ + /// + /// Triggered when session logout occurs + /// + /// context needed for handling logout event + /// + public Task HandleUserSessionLogout(UserSessionEventContext sessionEventContext); + + /// + /// Triggered when session expires + /// + /// context needed for handling logout event + /// + public Task HandleUserSessionExpiry(UserSessionEventContext sessionEventContext); +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs new file mode 100644 index 00000000..12ecead4 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs @@ -0,0 +1,343 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +#nullable enable + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Models; +using Open.IdentityServer.Services; +using Open.IdentityServer.Services.Default; +using Open.IdentityServer.Stores; +using Open.IdentityServer.UnitTests.Common; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Services.Default; + +public class DefaultUserSessionEventsServiceTests +{ + private readonly IBackChannelLogoutService backChannelLogoutService = Mock.Of(); + private readonly IClientStore clientStore = Mock.Of(); + private readonly IPersistedGrantStore persistedGrantStore = Mock.Of(); + private readonly IdentityServerOptions idsOptions = new(); + private readonly ILogger logger = TestLogger.Create(); + + private DefaultUserSessionEventsService CreateSut() => new(clientStore, persistedGrantStore, backChannelLogoutService, idsOptions, logger); + + [Fact] + public async Task HandleUserSessionLogout_WhenNoClientIdsInSession_ShouldDoNothing() + { + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = [] + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionLogout(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.IsAny()), Times.Never); + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task HandleUserSessionLogout_WhenServerDefaultCoordinateLifetimeSettingIsTrue_ShouldTriggerSessionCoordinationForClientsWithSettingEnabledAndFalse() + { + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession = true; + + List clients = [ + new() { ClientId = "fake-client-one", CoordinateLifetimeWithUserSession = null }, + new() { ClientId = "fake-client-two", CoordinateLifetimeWithUserSession = true }, + new() { ClientId = "fake-client-three", CoordinateLifetimeWithUserSession = false }, + ]; + + SetupClientStore(clients); + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = clients.Select(x => x.ClientId).ToArray() + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionLogout(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-one") && + x.ClientIds.Contains("fake-client-two") && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-one") && + x.ClientIds.Contains("fake-client-two") && + x.ClientIds.Contains("fake-client-three")))); + } + + [Fact] + public async Task HandleUserSessionLogout_WhenServerDefaultCoordinateLifetimeSettingIsFalse_ShouldTriggerSessionCoordinationForClientsWithSettingEnabledOnly() + { + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession = false; + + List clients = [ + new() { ClientId = "fake-client-one", CoordinateLifetimeWithUserSession = null }, + new() { ClientId = "fake-client-two", CoordinateLifetimeWithUserSession = true }, + new() { ClientId = "fake-client-three", CoordinateLifetimeWithUserSession = false }, + ]; + + SetupClientStore(clients); + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = clients.Select(x => x.ClientId).ToArray() + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionLogout(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-two") && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-one") && + x.ClientIds.Contains("fake-client-two") && + x.ClientIds.Contains("fake-client-three")))); + } + + [Fact] + public async Task HandleUserSessionLogout_WhenClientIdNotFound_ShouldExcludeClientIdsNotFound() + { + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession = false; + + List clients = [ + new() { ClientId = "fake-client-one", CoordinateLifetimeWithUserSession = true }, + ]; + + SetupClientStore(clients); + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = [..clients.Select(x => x.ClientId).ToList(), "fake-non-found"], + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionLogout(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-one") && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-one") && + x.ClientIds.Contains("fake-non-found")))); + } + + [Fact] + public async Task HandleUserSessionExpiry_WhenNoClientIdsInSession_ShouldDoNothing() + { + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = [], + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionExpiry(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.IsAny()), Times.Never); + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task HandleUserSessionExpiry_WhenServerDefaultCoordinateLifetimeSettingIsTrue_ShouldTriggerSessionCoordinationForClientsWithSettingEnabledAndFalse() + { + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession = true; + + List clients = [ + new() { ClientId = "fake-client-one", CoordinateLifetimeWithUserSession = null }, + new() { ClientId = "fake-client-two", CoordinateLifetimeWithUserSession = true }, + new() { ClientId = "fake-client-three", CoordinateLifetimeWithUserSession = false }, + ]; + + SetupClientStore(clients); + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = clients.Select(x => x.ClientId).ToArray(), + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionExpiry(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-one") && + x.ClientIds.Contains("fake-client-two") && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-one") && + x.ClientIds.Contains("fake-client-two")))); + } + + [Fact] + public async Task HandleUserSessionExpiry_WhenServerDefaultCoordinateLifetimeSettingIsFalse_ShouldTriggerSessionCoordinationForClientsWithSettingEnabledOnly() + { + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession = false; + + List clients = [ + new() { ClientId = "fake-client-one", CoordinateLifetimeWithUserSession = null }, + new() { ClientId = "fake-client-two", CoordinateLifetimeWithUserSession = true }, + new() { ClientId = "fake-client-three", CoordinateLifetimeWithUserSession = false }, + ]; + + SetupClientStore(clients); + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = clients.Select(x => x.ClientId).ToArray(), + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionExpiry(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-two") && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-two")))); + } + + [Fact] + public async Task HandleUserSessionExpiry_WhenExpiredSessionsTriggerBackchannelLogoutIsTrue_ShouldTriggerBackchannelLogoutOnAllClientIgnoringCoordinationSetting() + { + idsOptions.ServerSideSessions.ExpiredSessionsTriggerBackchannelLogout = true; + + List clients = [ + new() { ClientId = "fake-client-one", CoordinateLifetimeWithUserSession = null }, + new() { ClientId = "fake-client-two", CoordinateLifetimeWithUserSession = true }, + new() { ClientId = "fake-client-three", CoordinateLifetimeWithUserSession = false }, + ]; + + SetupClientStore(clients); + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = clients.Select(x => x.ClientId).ToArray(), + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionExpiry(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-two") && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-one") && + x.ClientIds.Contains("fake-client-two") && + x.ClientIds.Contains("fake-client-three")))); + } + + [Fact] + public async Task HandleUserSessionExpiry_WhenClientIdNotFound_ShouldExcludeClientIdsNotFound() + { + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = ["fake-non-found"], + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionExpiry(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.IsAny()), Times.Never); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.IsAny()), Times.Never); + } + + private void SetupClientStore(IEnumerable clients) + { + foreach (var client in clients) + { + Mock.Get(clientStore) + .Setup(x => x.FindClientByIdAsync(client.ClientId)) + .ReturnsAsync(client); + } + } +} \ No newline at end of file From 344208b06e65ab3a6ac3f770e6c1bb357d756994 Mon Sep 17 00:00:00 2001 From: James Britton Date: Tue, 11 Aug 2026 13:20:59 +0100 Subject: [PATCH 5/8] feat: added telemetry and logging with input validation --- .../Contexts/UserSessionEventContext.cs | 4 +- .../DefaultUserSessionEventsService.cs | 24 ++- .../DefaultUserSessionEventsServiceTests.cs | 148 ++++++++++++++---- 3 files changed, 142 insertions(+), 34 deletions(-) diff --git a/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs b/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs index 23e49610..b002b3cc 100644 --- a/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs +++ b/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs @@ -17,9 +17,9 @@ public class UserSessionEventContext /// Session identifier of the session the event has been triggered for /// public string SessionId { get; set; } - + /// /// ClientIds logged into with the user session /// - public string[] ClientIds { get; set; } + public string[] ClientIds { get; set; } = []; } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs b/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs index f0283a6c..22307017 100644 --- a/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs +++ b/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs @@ -3,6 +3,7 @@ #nullable enable +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -20,19 +21,27 @@ namespace Open.IdentityServer.Services.Default; /// persisted grant store /// back channel logout service /// IdentityServer options +/// telemetry service /// logger public class DefaultUserSessionEventsService( IClientStore clientStore, IPersistedGrantStore persistedGrantStore, IBackChannelLogoutService backChannelLogoutService, IdentityServerOptions idsOptions, + ITelemetryService telemetry, ILogger logger) : IUserSessionEventsService { /// public async Task HandleUserSessionLogout(UserSessionEventContext sessionEventContext) { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionEventContext.SessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionEventContext.SubjectId); + + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Services, this); + if (sessionEventContext.ClientIds.Length == 0) { + logger.LogInformation("no clients linked to session, nothing to be done"); return; } @@ -49,6 +58,11 @@ await backChannelLogoutService.SendLogoutNotificationsAsync(new LogoutNotificati /// public async Task HandleUserSessionExpiry(UserSessionEventContext sessionEventContext) { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionEventContext.SessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionEventContext.SubjectId); + + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Services, this); + var clientToNotify = await EndSessionForClients(sessionEventContext); var backChannelClients = (idsOptions.ServerSideSessions.ExpiredSessionsTriggerBackchannelLogout @@ -57,6 +71,7 @@ public async Task HandleUserSessionExpiry(UserSessionEventContext sessionEventCo if (backChannelClients.Count == 0) { + logger.LogInformation("no backchannel clients to notify"); return; } @@ -74,6 +89,7 @@ await backChannelLogoutService.SendLogoutNotificationsAsync(new LogoutNotificati if (clientIds.Length == 0) { + logger.LogInformation("no clients to remove grants for"); return null; } @@ -88,16 +104,14 @@ await persistedGrantStore.RemoveAllAsync(new PersistedGrantFilter return clientIds; } - private bool ShouldCoordinate(Client client) => client.CoordinateLifetimeWithUserSession ?? - idsOptions.Authentication.CoordinateClientLifetimesWithUserSession; - private async IAsyncEnumerable ClientIdsToCoordinate(UserSessionEventContext sessionEventContext) { - foreach (string clientId in sessionEventContext.ClientIds) + foreach (string clientId in sessionEventContext.ClientIds ?? []) { var client = await clientStore.FindClientByIdAsync(clientId); - if (client != null && ShouldCoordinate(client)) + if (client != null && (client.CoordinateLifetimeWithUserSession ?? + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession)) { yield return client.ClientId; } diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs index 12ecead4..e6d8ec9c 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs @@ -3,9 +3,11 @@ #nullable enable +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using AwesomeAssertions; using Microsoft.Extensions.Logging; using Moq; using Open.IdentityServer.Configuration; @@ -24,10 +26,40 @@ public class DefaultUserSessionEventsServiceTests private readonly IClientStore clientStore = Mock.Of(); private readonly IPersistedGrantStore persistedGrantStore = Mock.Of(); private readonly IdentityServerOptions idsOptions = new(); + private readonly ITelemetryService telemetry = Mock.Of(); + private readonly ITrace trace = Mock.Of(); private readonly ILogger logger = TestLogger.Create(); - private DefaultUserSessionEventsService CreateSut() => new(clientStore, persistedGrantStore, backChannelLogoutService, idsOptions, logger); + private DefaultUserSessionEventsService CreateSut() => new(clientStore, persistedGrantStore, backChannelLogoutService, idsOptions, telemetry, logger); + [Theory] + [InlineData("subjectId", null)] + [InlineData("subjectId", "")] + [InlineData("subjectId", " ")] + [InlineData(null, "subjectId")] + [InlineData("", "subjectId")] + [InlineData(" ", "subjectId")] + public async Task HandleUserSessionLogout_WhenInvalidSubjectId_ShouldThrowArgumentException(string subjectId, string sessionId) + { + UserSessionEventContext userSessionCtx = new() + { + SubjectId = subjectId, + SessionId = sessionId, + ClientIds = [] + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + Func act = async () => await sut.HandleUserSessionLogout(userSessionCtx); + + await act.Should().ThrowAsync(); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.IsAny()), Times.Never); + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.IsAny()), Times.Never); + } + [Fact] public async Task HandleUserSessionLogout_WhenNoClientIdsInSession_ShouldDoNothing() { @@ -75,11 +107,11 @@ public async Task HandleUserSessionLogout_WhenServerDefaultCoordinateLifetimeSet .Verify(x => x.RemoveAllAsync(It.Is(x => x.SubjectId == "fakeSubject" && x.SessionId == "fakeSession" && - x.ClientIds.Contains("fake-client-one") && - x.ClientIds.Contains("fake-client-two") && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.RefreshToken) && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + Enumerable.Contains(x.ClientIds, "fake-client-one") && + Enumerable.Contains(x.ClientIds, "fake-client-two") && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); Mock.Get(backChannelLogoutService) .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => @@ -117,10 +149,10 @@ public async Task HandleUserSessionLogout_WhenServerDefaultCoordinateLifetimeSet .Verify(x => x.RemoveAllAsync(It.Is(x => x.SubjectId == "fakeSubject" && x.SessionId == "fakeSession" && - x.ClientIds.Contains("fake-client-two") && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.RefreshToken) && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + Enumerable.Contains(x.ClientIds, "fake-client-two") && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); Mock.Get(backChannelLogoutService) .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => @@ -156,10 +188,10 @@ public async Task HandleUserSessionLogout_WhenClientIdNotFound_ShouldExcludeClie .Verify(x => x.RemoveAllAsync(It.Is(x => x.SubjectId == "fakeSubject" && x.SessionId == "fakeSession" && - x.ClientIds.Contains("fake-client-one") && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.RefreshToken) && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + Enumerable.Contains(x.ClientIds, "fake-client-one") && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); Mock.Get(backChannelLogoutService) .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => @@ -168,6 +200,34 @@ public async Task HandleUserSessionLogout_WhenClientIdNotFound_ShouldExcludeClie x.ClientIds.Contains("fake-client-one") && x.ClientIds.Contains("fake-non-found")))); } + + [Theory] + [InlineData("subjectId", null)] + [InlineData("subjectId", "")] + [InlineData("subjectId", " ")] + [InlineData(null, "subjectId")] + [InlineData("", "subjectId")] + [InlineData(" ", "subjectId")] + public async Task HandleUserSessionExpiry_WhenInvalidSubjectId_ShouldThrowArgumentException(string subjectId, string sessionId) + { + UserSessionEventContext userSessionCtx = new() + { + SubjectId = subjectId, + SessionId = sessionId, + ClientIds = [] + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + Func act = async () => await sut.HandleUserSessionExpiry(userSessionCtx); + + await act.Should().ThrowAsync(); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.IsAny()), Times.Never); + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.IsAny()), Times.Never); + } [Fact] public async Task HandleUserSessionExpiry_WhenNoClientIdsInSession_ShouldDoNothing() @@ -216,11 +276,11 @@ public async Task HandleUserSessionExpiry_WhenServerDefaultCoordinateLifetimeSet .Verify(x => x.RemoveAllAsync(It.Is(x => x.SubjectId == "fakeSubject" && x.SessionId == "fakeSession" && - x.ClientIds.Contains("fake-client-one") && - x.ClientIds.Contains("fake-client-two") && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.RefreshToken) && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + Enumerable.Contains(x.ClientIds, "fake-client-one") && + Enumerable.Contains(x.ClientIds, "fake-client-two") && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); Mock.Get(backChannelLogoutService) .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => @@ -257,10 +317,10 @@ public async Task HandleUserSessionExpiry_WhenServerDefaultCoordinateLifetimeSet .Verify(x => x.RemoveAllAsync(It.Is(x => x.SubjectId == "fakeSubject" && x.SessionId == "fakeSession" && - x.ClientIds.Contains("fake-client-two") && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.RefreshToken) && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + Enumerable.Contains(x.ClientIds, "fake-client-two") && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); Mock.Get(backChannelLogoutService) .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => @@ -296,10 +356,10 @@ public async Task HandleUserSessionExpiry_WhenExpiredSessionsTriggerBackchannelL .Verify(x => x.RemoveAllAsync(It.Is(x => x.SubjectId == "fakeSubject" && x.SessionId == "fakeSession" && - x.ClientIds.Contains("fake-client-two") && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.RefreshToken) && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + Enumerable.Contains(x.ClientIds, "fake-client-two") && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); Mock.Get(backChannelLogoutService) .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => @@ -330,6 +390,40 @@ public async Task HandleUserSessionExpiry_WhenClientIdNotFound_ShouldExcludeClie Mock.Get(backChannelLogoutService) .Verify(x => x.SendLogoutNotificationsAsync(It.IsAny()), Times.Never); } + + [Fact] + public async Task HandleUserSessionLogout_WhenCalled_ShouldInitiateTelemetryTrace() + { + Mock.Get(telemetry) + .Setup(t => t.Trace(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(trace); + + DefaultUserSessionEventsService sut = CreateSut(); + await sut.HandleUserSessionLogout(new UserSessionEventContext { SessionId = "session", SubjectId = "subject" }); + + Mock.Get(telemetry) + .Verify(t => t.Trace( + TelemetryConstants.TraceCategories.Services, sut, "HandleUserSessionLogout")); + Mock.Get(trace) + .Verify(t => t.Dispose(), Times.Once); + } + + [Fact] + public async Task HandleUserSessionExpiry_WhenCalled_ShouldInitiateTelemetryTrace() + { + Mock.Get(telemetry) + .Setup(t => t.Trace(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(trace); + + DefaultUserSessionEventsService sut = CreateSut(); + await sut.HandleUserSessionExpiry(new UserSessionEventContext { SessionId = "session", SubjectId = "subject" }); + + Mock.Get(telemetry) + .Verify(t => t.Trace( + TelemetryConstants.TraceCategories.Services, sut, "HandleUserSessionExpiry")); + Mock.Get(trace) + .Verify(t => t.Dispose(), Times.Once); + } private void SetupClientStore(IEnumerable clients) { From d847b5c3c7217c737093f492c9e9a05afa8c94ff Mon Sep 17 00:00:00 2001 From: James Britton Date: Tue, 11 Aug 2026 14:53:37 +0100 Subject: [PATCH 6/8] feat: link up lohout event and handler created in user session events service --- .../BuilderExtensions/Additional.cs | 2 + .../src/Hosting/IdentityServerMiddleware.cs | 21 ++++--- .../Hosting/IdentityServerMiddlewareTests.cs | 57 ++++++++++++++++++- 3 files changed, 72 insertions(+), 8 deletions(-) diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs index fa525a37..96bd2140 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs @@ -16,6 +16,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Open.IdentityServer.Models; +using Open.IdentityServer.Services.Default; namespace Microsoft.Extensions.DependencyInjection; @@ -473,6 +474,7 @@ public static IIdentityServerBuilder AddServerSideSessions(this IIdentityServerB { builder.Services.AddSingleton, PostConfigureSessionStoreCookieAuthOptions>(); builder.Services.AddScoped(); + builder.Services.AddScoped(); // provide default in-memory implementation, not suitable for most production scenarios (following pattern implemented with existing stores) builder.Services.TryAddSingleton(); diff --git a/src/Open.IdentityServer/src/Hosting/IdentityServerMiddleware.cs b/src/Open.IdentityServer/src/Hosting/IdentityServerMiddleware.cs index 31a180a5..895364ed 100644 --- a/src/Open.IdentityServer/src/Hosting/IdentityServerMiddleware.cs +++ b/src/Open.IdentityServer/src/Hosting/IdentityServerMiddleware.cs @@ -9,7 +9,9 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using System; +using System.Linq; using System.Threading.Tasks; +using Open.IdentityServer.Models; namespace Open.IdentityServer.Hosting; @@ -40,6 +42,7 @@ public IdentityServerMiddleware(RequestDelegate next, ILoggerThe user session. /// The event service. /// The service used to send back-channel logout notifications to clients when the user signs out. + /// The service for handling user session events /// The telemetry service /// A task that completes when the request has been handled by an IdentityServer endpoint or passed to the next middleware in the pipeline. public async Task Invoke( @@ -47,7 +50,8 @@ public async Task Invoke( IEndpointRouter router, IUserSession session, IEventService events, - IBackChannelLogoutService backChannelLogoutService, + IBackChannelLogoutService backChannelLogoutService, + IUserSessionEventsService userSessionEventsService, ITelemetryService telemetryService) { // this will check the authentication session and from it emit the check session @@ -62,13 +66,16 @@ public async Task Invoke( // this clears our session id cookie so JS clients can detect the user has signed out await session.RemoveSessionIdCookieAsync(); - - // back channel logout - var logoutContext = await session.GetLogoutNotificationContext(); - if (logoutContext != null) + + // notify other services of logout when required + var user = await session.GetUserAsync(); + var clientIds = await session.GetClientListAsync(); + await userSessionEventsService.HandleUserSessionLogout(new UserSessionEventContext { - await backChannelLogoutService.SendLogoutNotificationsAsync(logoutContext); - } + SessionId = await session.GetSessionIdAsync(), + SubjectId = user.GetSubjectId(), + ClientIds = clientIds.ToArray(), + }); } }); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Hosting/IdentityServerMiddlewareTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Hosting/IdentityServerMiddlewareTests.cs index a93d1bf1..8a788c2d 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Hosting/IdentityServerMiddlewareTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Hosting/IdentityServerMiddlewareTests.cs @@ -1,13 +1,20 @@ // Copyright (c) 2026, Rock Solid Knowledge Ltd +// Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. +#nullable enable + using System; +using System.Security.Claims; using System.Threading.Tasks; using AwesomeAssertions; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Logging; using Moq; +using Open.IdentityServer.Extensions; using Open.IdentityServer.Hosting; +using Open.IdentityServer.Models; using Open.IdentityServer.Services; using Xunit; @@ -25,6 +32,7 @@ public class IdentityServerMiddlewareTests private readonly Mock _userSession; private readonly Mock _eventService; private readonly Mock _backChannelLogoutService; + private readonly IUserSessionEventsService userSessionEventsService; private readonly Mock _telemetryService; private readonly Mock _trace; private readonly DefaultHttpContext _context; @@ -41,6 +49,7 @@ public IdentityServerMiddlewareTests() _userSession = new Mock(); _eventService = new Mock(); _backChannelLogoutService = new Mock(); + userSessionEventsService = Mock.Of(); _telemetryService = new Mock(); _trace = new Mock(); _telemetryService.Setup(t => t.Trace(It.IsAny(), It.IsAny())) @@ -57,7 +66,7 @@ public IdentityServerMiddlewareTests() private async Task InvokeSubjectMiddleware() { await _subject.Invoke(_context, _router.Object, _userSession.Object, - _eventService.Object, _backChannelLogoutService.Object, _telemetryService?.Object); + _eventService.Object, _backChannelLogoutService.Object, userSessionEventsService, _telemetryService?.Object); } [Fact] @@ -208,4 +217,50 @@ public async Task Invoke_WhenRouterLocatesEndpoint_ShouldTrackActiveRequest() Times.Once); activeRequestDisposable.Verify(x => x.Dispose(), Times.Once); } + + [Fact] + public async Task Invoke_WhenSignOutCalled_ShouldCallHandleUserSessionLogout_OnIUserSessionEventsService() + { + string sessionId = "session-id"; + string subjectId = "subject-id"; + string[] clientIds = ["clientId1", "clientId2", "clientId3"]; + ClaimsPrincipal user = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, subjectId, ClaimValueTypes.String, "FakeIssuer"), + ])); + + // Manually invoke the OnStarting callback to test that user session events service is called + var responseFeatureMock = Mock.Of(); + Mock.Get(responseFeatureMock) + .Setup(x => x.OnStarting(It.IsAny>(), It.IsAny())) + .Callback, object>((callback, state) => { callback.Invoke(state); }); + _context.Features[typeof(IHttpResponseFeature)] = responseFeatureMock; + + _context.SetSignOutCalled(); + + _userSession.Setup(x => x.GetClientListAsync()) + .ReturnsAsync(clientIds); + _userSession.Setup(x => x.GetSessionIdAsync()) + .ReturnsAsync(sessionId); + _userSession.Setup(x => x.GetUserAsync()) + .ReturnsAsync(user); + + _userSession.Setup(x => x.RemoveSessionIdCookieAsync()).Returns(Task.CompletedTask); + + UserSessionEventContext? actualUserSessionEventCtx = null; + Mock.Get(userSessionEventsService) + .Setup(x => x.HandleUserSessionLogout(It.IsAny())) + .Callback((sessionEventContext) => { actualUserSessionEventCtx = sessionEventContext; }); + + await InvokeSubjectMiddleware(); + + _userSession.Verify(x => x.RemoveSessionIdCookieAsync(), Times.Once); + + Mock.Get(userSessionEventsService) + .Verify(x => x.HandleUserSessionLogout(It.IsAny()), Times.Once); + + actualUserSessionEventCtx.Should().NotBeNull(); + actualUserSessionEventCtx.SessionId.Should().BeEquivalentTo(sessionId); + actualUserSessionEventCtx.SubjectId.Should().BeEquivalentTo(subjectId); + actualUserSessionEventCtx.ClientIds.Should().BeEquivalentTo(clientIds); + } } \ No newline at end of file From a7da644167de4853f429bbca3bbdc30a53c9d9cc Mon Sep 17 00:00:00 2001 From: James Britton Date: Tue, 25 Aug 2026 14:21:23 +0100 Subject: [PATCH 7/8] fix: broken tests due to missing dependency when server side sessions disabled --- .../DependencyInjection/BuilderExtensions/Additional.cs | 2 +- .../DependencyInjection/BuilderExtensions/Core.cs | 2 ++ .../src/Hosting/IdentityServerMiddleware.cs | 7 ++++++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs index 96bd2140..da0abc1a 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs @@ -474,7 +474,7 @@ public static IIdentityServerBuilder AddServerSideSessions(this IIdentityServerB { builder.Services.AddSingleton, PostConfigureSessionStoreCookieAuthOptions>(); builder.Services.AddScoped(); - builder.Services.AddScoped(); + // builder.Services.AddScoped(); // provide default in-memory implementation, not suitable for most production scenarios (following pattern implemented with existing stores) builder.Services.TryAddSingleton(); diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs index 7cd2d400..cf62e427 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs @@ -136,6 +136,8 @@ public static IIdentityServerBuilder AddCoreServices(this IIdentityServerBuilder builder.Services.AddCors(); builder.Services.AddTransientDecorator(); + + builder.Services.AddScoped(); return builder; } diff --git a/src/Open.IdentityServer/src/Hosting/IdentityServerMiddleware.cs b/src/Open.IdentityServer/src/Hosting/IdentityServerMiddleware.cs index 895364ed..38d4cb4a 100644 --- a/src/Open.IdentityServer/src/Hosting/IdentityServerMiddleware.cs +++ b/src/Open.IdentityServer/src/Hosting/IdentityServerMiddleware.cs @@ -2,7 +2,6 @@ // Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. - using Open.IdentityServer.Events; using Open.IdentityServer.Extensions; using Open.IdentityServer.Services; @@ -70,6 +69,12 @@ public async Task Invoke( // notify other services of logout when required var user = await session.GetUserAsync(); var clientIds = await session.GetClientListAsync(); + + if (user == null) + { + return; + } + await userSessionEventsService.HandleUserSessionLogout(new UserSessionEventContext { SessionId = await session.GetSessionIdAsync(), From 8ad10a39d4f3fc9604eba65a816b8edc10388738 Mon Sep 17 00:00:00 2001 From: James Britton Date: Fri, 28 Aug 2026 13:03:36 +0100 Subject: [PATCH 8/8] pr: correcting issues spotted in review --- .../src/Stores/PersistedGrantStore.cs | 22 ------------------- .../BuilderExtensions/Additional.cs | 1 - .../Options/AuthenticationOptions.cs | 2 +- .../Options/ServerSideSessionsOptions.cs | 6 +++-- .../Contexts/UserSessionEventContext.cs | 8 +++---- src/Storage/src/Models/Client.cs | 7 +++++- 6 files changed, 15 insertions(+), 31 deletions(-) diff --git a/src/EntityFramework.Storage/src/Stores/PersistedGrantStore.cs b/src/EntityFramework.Storage/src/Stores/PersistedGrantStore.cs index cb84c24e..a109e94e 100644 --- a/src/EntityFramework.Storage/src/Stores/PersistedGrantStore.cs +++ b/src/EntityFramework.Storage/src/Stores/PersistedGrantStore.cs @@ -101,7 +101,6 @@ public virtual async Task GetAsync(string key) public async Task> GetAllAsync(PersistedGrantFilter filter) { using var trace = Telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); - AddFilterTags(trace, filter); filter.Validate(); @@ -147,7 +146,6 @@ public virtual async Task RemoveAsync(string key) public async Task RemoveAllAsync(PersistedGrantFilter filter) { using var trace = Telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); - AddFilterTags(trace, filter); filter.Validate(); @@ -168,26 +166,6 @@ public async Task RemoveAllAsync(PersistedGrantFilter filter) } } - private void AddFilterTags(ITrace trace, PersistedGrantFilter filter) - { - if (trace == null) return; - - var clientIds = filter.ClientIds.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); - if (clientIds.Any()) - { - trace.AddTag(TelemetryConstants.TagConstants.Client, string.Join(",", filter.ClientIds)); - } - if (!string.IsNullOrWhiteSpace(filter.SubjectId)) - { - trace.AddTag(TelemetryConstants.TagConstants.Subject, filter.SubjectId); - } - var types = filter.Types.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); - if (types.Any()) - { - trace.AddTag(TelemetryConstants.TagConstants.GrantType, string.Join(",", filter.Types)); - } - } - private IQueryable Filter(IQueryable query, PersistedGrantFilter filter) { var clientIds = filter.ClientIds.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs index da0abc1a..aad5d339 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs @@ -474,7 +474,6 @@ public static IIdentityServerBuilder AddServerSideSessions(this IIdentityServerB { builder.Services.AddSingleton, PostConfigureSessionStoreCookieAuthOptions>(); builder.Services.AddScoped(); - // builder.Services.AddScoped(); // provide default in-memory implementation, not suitable for most production scenarios (following pattern implemented with existing stores) builder.Services.TryAddSingleton(); diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/AuthenticationOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/AuthenticationOptions.cs index 28d72c89..848025a8 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/AuthenticationOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/AuthenticationOptions.cs @@ -62,7 +62,7 @@ public class AuthenticationOptions public bool RequireCspFrameSrcForSignout { get; set; } = true; /// - /// If set, refresh token lifetimes will be tied to the users' session. This setting can be overridden at a client + /// If set, refresh token lifetimes will be tied to the users' session. This setting can be overridden at the client /// level. /// public bool CoordinateClientLifetimesWithUserSession { get; set; } diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs index 76b5f41e..e9c0b12e 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs @@ -4,12 +4,14 @@ namespace Open.IdentityServer.Configuration; /// -/// +/// Server side sessions options. /// public class ServerSideSessionsOptions { /// - /// + /// Specifies if session expiry should trigger back channel logout, this will override any other settings that may + /// cause back channel logout such as AuthenticationOptions.CoordinateClientLifetimesWithUserSession or + /// Client.CoordinateLifetimeWithUserSession. /// public bool ExpiredSessionsTriggerBackchannelLogout { get; set; } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs b/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs index b002b3cc..3298d0bc 100644 --- a/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs +++ b/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs @@ -4,22 +4,22 @@ namespace Open.IdentityServer.Models; /// -/// Provides the context necessary to handle user session events +/// Provides the context for handling user session events /// public class UserSessionEventContext { /// - /// Subject identifier of the user of the session the event has been triggered for + /// Subject identifier of the user session the event has been triggered for /// public string SubjectId { get; set; } /// - /// Session identifier of the session the event has been triggered for + /// Session identifier of the user session the event has been triggered for /// public string SessionId { get; set; } /// - /// ClientIds logged into with the user session + /// ClientIds of the user session the event has been triggered for /// public string[] ClientIds { get; set; } = []; } \ No newline at end of file diff --git a/src/Storage/src/Models/Client.cs b/src/Storage/src/Models/Client.cs index 025e3695..94d4bdfd 100644 --- a/src/Storage/src/Models/Client.cs +++ b/src/Storage/src/Models/Client.cs @@ -432,8 +432,13 @@ IEnumerator IEnumerable.GetEnumerator() } /// - /// Gets or sets the coordinate lifetime with the user session + /// Used to override the server default value configured with IdentityServerOptions.Authentication.CoordinateClientLifetimesWithUserSession. + /// Specifies if the user session ending should revoke client revocable tokens, and also if token validation should + /// check for a valid user session. /// + /// + /// true if coordination enabled; false if coordination disabled; otherwise, null. + /// public bool? CoordinateLifetimeWithUserSession { get; set; } //Unused Compatibility Properties