From 02c655b6082d2df39e59ae86351c5c40287ca9b5 Mon Sep 17 00:00:00 2001 From: Oleks Dovzhenko Date: Wed, 28 Oct 2026 12:33:34 +0100 Subject: [PATCH 1/3] fix: Send back-channel logout from expired session cleanup --- .../src/Models/Contexts/EndUserSessionEventContext.cs | 5 +++++ .../src/Models/Contexts/LogoutNotificationContext.cs | 5 +++++ .../src/Services/Default/DefaultBackChannelLogoutService.cs | 4 +++- .../src/Services/Default/DefaultSessionManagementService.cs | 1 + .../src/Services/Default/DefaultUserSessionEventsService.cs | 2 ++ .../src/Services/Default/LogoutNotificationService.cs | 3 ++- .../src/Services/Default/SessionCleanupService.cs | 1 + .../src/Services/ILogoutNotificationService.cs | 5 +++++ 8 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/Open.IdentityServer/src/Models/Contexts/EndUserSessionEventContext.cs b/src/Open.IdentityServer/src/Models/Contexts/EndUserSessionEventContext.cs index 94261f03c..d17f5ea84 100644 --- a/src/Open.IdentityServer/src/Models/Contexts/EndUserSessionEventContext.cs +++ b/src/Open.IdentityServer/src/Models/Contexts/EndUserSessionEventContext.cs @@ -9,4 +9,9 @@ public class EndUserSessionEventContext: UserSessionEventContext /// Collection of ClientIds active within the session. /// public string[] ClientIds { get; set; } = []; + + /// + /// The issuer of the user's session. + /// + public string Issuer { get; set; } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Models/Contexts/LogoutNotificationContext.cs b/src/Open.IdentityServer/src/Models/Contexts/LogoutNotificationContext.cs index 59c8d3aaf..b13989734 100644 --- a/src/Open.IdentityServer/src/Models/Contexts/LogoutNotificationContext.cs +++ b/src/Open.IdentityServer/src/Models/Contexts/LogoutNotificationContext.cs @@ -24,4 +24,9 @@ public class LogoutNotificationContext /// The list of client Ids that the user has authenticated to. /// public IEnumerable ClientIds { get; set; } + + /// + /// The issuer of the user's session. + /// + public string Issuer { get; set; } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Services/Default/DefaultBackChannelLogoutService.cs b/src/Open.IdentityServer/src/Services/Default/DefaultBackChannelLogoutService.cs index 85495b448..7fac1c753 100644 --- a/src/Open.IdentityServer/src/Services/Default/DefaultBackChannelLogoutService.cs +++ b/src/Open.IdentityServer/src/Services/Default/DefaultBackChannelLogoutService.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using Open.IdentityServer.Models; using Microsoft.Extensions.Logging; +using Open.IdentityServer.Extensions; using Open.IdentityServer.Utility; namespace Open.IdentityServer.Services; @@ -152,7 +153,8 @@ protected virtual async Task CreateTokenAsync(BackChannelLogoutRequest r throw new InvalidOperationException("nonce claim is not allowed in the back-channel signout token."); } - return await Tools.IssueJwtAsync(DefaultLogoutTokenLifetime, claims); + var issuer = request.Issuer ?? Tools.ContextAccessor.HttpContext.GetIdentityServerIssuerUri(); + return await Tools.IssueJwtAsync(DefaultLogoutTokenLifetime, issuer, claims); } /// diff --git a/src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs b/src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs index 74fd057e3..966e87f31 100644 --- a/src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs +++ b/src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs @@ -62,6 +62,7 @@ await backChannelLogoutService.SendLogoutNotificationsAsync(new LogoutNotificati SubjectId = session.Session.SubjectId, SessionId = session.Session.SessionId, ClientIds = context.ClientIds.IsNullOrEmpty() ? sessionClientList : clientIds, + Issuer = session.AuthTicket?.Properties.GetString(JwtClaimTypes.Issuer) }); } } diff --git a/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs b/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs index d5fe669e5..3c4890317 100644 --- a/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs +++ b/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs @@ -57,6 +57,7 @@ await backChannelLogoutService.SendLogoutNotificationsAsync(new LogoutNotificati SubjectId = sessionEventContext.SubjectId, SessionId = sessionEventContext.SessionId, ClientIds = sessionEventContext.ClientIds, + Issuer = sessionEventContext.Issuer, }); } @@ -85,6 +86,7 @@ await backChannelLogoutService.SendLogoutNotificationsAsync(new LogoutNotificati SubjectId = sessionEventContext.SubjectId, SessionId = sessionEventContext.SessionId, ClientIds = backChannelClients, + Issuer = sessionEventContext.Issuer, }); } diff --git a/src/Open.IdentityServer/src/Services/Default/LogoutNotificationService.cs b/src/Open.IdentityServer/src/Services/Default/LogoutNotificationService.cs index f7ae784a3..eeac59cf9 100644 --- a/src/Open.IdentityServer/src/Services/Default/LogoutNotificationService.cs +++ b/src/Open.IdentityServer/src/Services/Default/LogoutNotificationService.cs @@ -108,7 +108,8 @@ public async Task> GetBackChannelLogoutNot LogoutUri = client.BackChannelLogoutUri, SubjectId = context.SubjectId, SessionId = context.SessionId, - SessionIdRequired = client.BackChannelLogoutSessionRequired + SessionIdRequired = client.BackChannelLogoutSessionRequired, + Issuer = context.Issuer, }; backChannelLogouts.Add(back); diff --git a/src/Open.IdentityServer/src/Services/Default/SessionCleanupService.cs b/src/Open.IdentityServer/src/Services/Default/SessionCleanupService.cs index ad655cbaa..700dcb433 100644 --- a/src/Open.IdentityServer/src/Services/Default/SessionCleanupService.cs +++ b/src/Open.IdentityServer/src/Services/Default/SessionCleanupService.cs @@ -71,6 +71,7 @@ await userSessionEventsService.HandleUserSessionExpiry(new EndUserSessionEventCo SubjectId = expiredSession.Session.SubjectId, SessionId = expiredSession.Session.SessionId, ClientIds = expiredSession.AuthTicket?.Properties.GetClientList().ToArray() ?? [], + Issuer = expiredSession.AuthTicket?.Properties.GetString(JwtClaimTypes.Issuer) }); } } while (found >= options.ServerSideSessions.RemoveExpiredSessionsBatchSize); diff --git a/src/Open.IdentityServer/src/Services/ILogoutNotificationService.cs b/src/Open.IdentityServer/src/Services/ILogoutNotificationService.cs index 04e27ce6a..633a1e549 100644 --- a/src/Open.IdentityServer/src/Services/ILogoutNotificationService.cs +++ b/src/Open.IdentityServer/src/Services/ILogoutNotificationService.cs @@ -54,4 +54,9 @@ public class BackChannelLogoutRequest /// Gets a value indicating whether the session identifier is required. /// public bool SessionIdRequired { get; set; } + + /// + /// Gets or sets the issuer of the user's session. + /// + public string Issuer { get; set; } } \ No newline at end of file From 74ea1cd8cd42c3fd477a1f9fdd1ec0567aa5fe73 Mon Sep 17 00:00:00 2001 From: Oleks Dovzhenko Date: Wed, 28 Oct 2026 12:33:34 +0100 Subject: [PATCH 2/3] test: Cover the back-channel logout issuer Co-Authored-By: Claude Opus 5.5 --- .../SessionCleanupServerSideSessionTests.cs | 44 +++++++++++++++++++ .../DefaultBackChannelLogoutServiceTests.cs | 28 +++++++++++- .../DefaultSessionManagementServiceTests.cs | 19 ++++++++ .../DefaultUserSessionEventsServiceTests.cs | 42 ++++++++++++++++++ .../Default/LogoutNotificationServiceTests.cs | 19 ++++++++ .../Default/SessionCleanupServiceTests.cs | 24 ++++++++++ 6 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/SessionCleanupServerSideSessionTests.cs diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/SessionCleanupServerSideSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/SessionCleanupServerSideSessionTests.cs new file mode 100644 index 000000000..b28d7819e --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/SessionCleanupServerSideSessionTests.cs @@ -0,0 +1,44 @@ +#nullable enable + +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Threading.Tasks; +using AwesomeAssertions; +using IdentityServer.IntegrationTests.Common; +using Microsoft.Extensions.DependencyInjection; +using Open.IdentityServer.Services; +using Xunit; + +namespace Open.IdentityServer.IntegrationTests; + +public class SessionCleanupServerSideSessionTests : ServerSideSessionTests +{ + private const string Category = nameof(SessionCleanupServerSideSessionTests); + + [Fact] + [Trait("Category", Category)] + public async Task RemoveExpiredServerSideSessions_ShouldSendBackChannelLogoutWithSessionIssuer() + { + _mockPipeline.Clients.Single(x => x.ClientId == "client1").BackChannelLogoutUri = "https://client1/backchannel"; + + // assert outside OnInvoke: BackChannelLogoutHttpClient swallows exceptions thrown by the handler + string? form = null; + _mockPipeline.BackChannelMessageHandler.OnInvoke = async req => form = await req.Content!.ReadAsStringAsync(); + + await _mockPipeline.LoginAsync("bob"); + await AuthCodeRequest("client1", "openid", "https://client1/callback"); + + fakeTimeProvider.Advance(TimeSpan.FromMinutes(7)); + + // no HttpContext here, as in the session cleanup hosted service + using (var scope = _mockPipeline.Server!.Services.CreateScope()) + { + await scope.ServiceProvider.GetRequiredService().RemoveExpiredServerSideSessionsAsync(); + } + + form.Should().StartWith(OidcConstants.BackChannelLogoutRequest.LogoutToken + "="); + var token = new JwtSecurityTokenHandler().ReadJwtToken(form!.Split('=')[1]); + token.Issuer.Should().Be(IdentityServerPipeline.BaseUrl); + } +} diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultBackChannelLogoutServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultBackChannelLogoutServiceTests.cs index 55fb2edf3..b50e751ce 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultBackChannelLogoutServiceTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultBackChannelLogoutServiceTests.cs @@ -29,11 +29,11 @@ public DefaultBackChannelLogoutServiceTests() _tools = new MockIdentityServerTools(Mock.Of(), _tokenCreation.Object, _clock); } - private DefaultBackChannelLogoutService CreateSubject() + private DefaultBackChannelLogoutService CreateSubject(IdentityServerTools tools = null) { return new DefaultBackChannelLogoutService( _clock, - _tools, + tools ?? _tools, _logoutNotification.Object, _backChannelLogoutHttpClient.Object, _telemetry.Object, @@ -58,6 +58,30 @@ public async Task SendLogoutNotificationAsync_WhenCalled_ShouldInitiateTelemetry TelemetryConstants.TraceCategories.Services, subject, "SendLogoutNotificationsAsync")); _trace.Verify(t => t.Dispose(), Times.Once); } + + [Fact] + public async Task SendLogoutNotificationsAsync_WhenRequestHasIssuer_ShouldIssueTokenWithoutHttpContext() + { + _logoutNotification.Setup(ln => ln.GetBackChannelLogoutNotificationsAsync(It.IsAny())) + .ReturnsAsync([ + new BackChannelLogoutRequest + { + ClientId = "client1", + SubjectId = "bob", + SessionId = "session1", + LogoutUri = "https://client1/logout", + Issuer = "https://issuer.test" + } + ]); + + // real tools, as MockIdentityServerTools drops the issuer; the accessor has no HttpContext, + // as when the session cleanup service sends the notification + var subject = CreateSubject(new IdentityServerTools(Mock.Of(), _tokenCreation.Object, _clock)); + + await subject.SendLogoutNotificationsAsync(new LogoutNotificationContext()); + + _tokenCreation.Verify(x => x.CreateTokenAsync(It.Is(t => t.Issuer == "https://issuer.test")), Times.Once); + } } internal class MockIdentityServerTools : IdentityServerTools diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultSessionManagementServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultSessionManagementServiceTests.cs index 0e7f47190..e90ed10c9 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultSessionManagementServiceTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultSessionManagementServiceTests.cs @@ -236,6 +236,25 @@ public async Task RemoveSessionsAsync_WhenClientIdsProvided_ShouldOnlyTriggerBac Mock.Get(serverSessionStore) .Verify(x => x.DeleteSessions(fakeContext.SubjectId, fakeContext.SessionId)); } + + [Fact] + public async Task RemoveSessionsAsync_WhenSessionHasIssuer_ShouldPassIssuerToLogoutNotification() + { + var fakeSession = ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key2", "SchemeA", "alice", "session-0002", "Alice", issuer: "https://issuer.test"); + + Mock.Get(serverSessionTicketStore) + .Setup(x => x.FilterServerAuthenticationTickets("alice", "session-0002")) + .ReturnsAsync([fakeSession]); + + DefaultSessionManagementService sut = CreateSut(); + + await sut.RemoveSessionsAsync(new RemoveSessionsContext { SubjectId = "alice", SessionId = "session-0002" }, + TestContext.Current.CancellationToken); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(ctx => + ctx.Issuer == "https://issuer.test"))); + } [Fact] public async Task RemoveSessionsAsync_WhenRemoveSessionsSetToFalse_ShouldNotRemoveSessions() 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 89b404040..bd8134095 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 @@ -233,6 +233,26 @@ public async Task HandleUserSessionLogout_WhenClientIdNotFound_ShouldExcludeClie c.ClientIds.Contains("fake-non-found")))); } + [Fact] + public async Task HandleUserSessionLogout_WhenContextHasIssuer_ShouldPassIssuerToLogoutNotification() + { + EndUserSessionEventContext endUserSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = ["fake-client-one"], + Issuer = "https://issuer.test" + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionLogout(endUserSessionCtx); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(c => + c.Issuer == "https://issuer.test"))); + } + [Theory] [InlineData("subjectId", null)] [InlineData("subjectId", "")] @@ -423,6 +443,28 @@ public async Task HandleUserSessionExpiry_WhenClientIdNotFound_ShouldExcludeClie .Verify(x => x.SendLogoutNotificationsAsync(It.IsAny()), Times.Never); } + [Fact] + public async Task HandleUserSessionExpiry_WhenContextHasIssuer_ShouldPassIssuerToLogoutNotification() + { + idsOptions.ServerSideSessions.ExpiredSessionsTriggerBackchannelLogout = true; + + EndUserSessionEventContext endUserSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = ["fake-client-one"], + Issuer = "https://issuer.test" + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionExpiry(endUserSessionCtx); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(c => + c.Issuer == "https://issuer.test"))); + } + [Fact] private async Task ValidateRefreshTokenAsync_WhenAuthTicketStoreRegistered_ShouldReturnTrue() { diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/LogoutNotificationServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/LogoutNotificationServiceTests.cs index 17dae364e..e8f09d4fa 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/LogoutNotificationServiceTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/LogoutNotificationServiceTests.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. using System.Threading.Tasks; +using AwesomeAssertions; using Microsoft.AspNetCore.Http; using Moq; using Open.IdentityServer.Models; @@ -78,4 +79,22 @@ await subject.GetBackChannelLogoutNotificationsAsync(new LogoutNotificationConte "GetBackChannelLogoutNotificationsAsync")); _trace.Verify(t => t.Dispose(), Times.Once); } + + [Fact] + public async Task GetBackChannelLogoutNotificationsAsync_WhenContextHasIssuer_ShouldSetIssuerOnRequests() + { + _clientStore.Setup(x => x.FindClientByIdAsync("client1")) + .ReturnsAsync(new Client { ClientId = "client1", BackChannelLogoutUri = "https://client1/logout" }); + + var subject = CreateSubject(); + + var requests = await subject.GetBackChannelLogoutNotificationsAsync(new LogoutNotificationContext + { + ClientIds = new[] { "client1" }, + SessionId = "session1", + Issuer = "https://issuer.test" + }); + + requests.Should().ContainSingle().Which.Issuer.Should().Be("https://issuer.test"); + } } \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/SessionCleanupServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/SessionCleanupServiceTests.cs index 51944daac..a920fa656 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/SessionCleanupServiceTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/SessionCleanupServiceTests.cs @@ -137,6 +137,30 @@ public async Task RemoveExpiredServerSideSessionsAsync_WhenExpiredSessionContain context.ClientIds.Should().BeEquivalentTo(fakeClientIds); } + [Fact] + public async Task RemoveExpiredServerSideSessionsAsync_WhenExpiredSessionHasIssuer_ExpectIssuerPassedToSessionExpiry() + { + var expiredSession = FakeSessionSession("123", "sesh1", true); + expiredSession.AuthTicket!.Properties.Items[JwtClaimTypes.Issuer] = "https://issuer.test"; + + Mock.Get(serverSideSessionStore) + .SetupSequence(x => + x.GetAndRemoveExpiredSessions(options.ServerSideSessions.RemoveExpiredSessionsBatchSize)) + .ReturnsAsync([expiredSession]); + + EndUserSessionEventContext? context = null; + Mock.Get(userSessionEventsService) + .Setup(x => x.HandleUserSessionExpiry(It.IsAny())) + .Callback(x => context = x); + + var sut = CreateSut(); + + await sut.RemoveExpiredServerSideSessionsAsync(); + + context.Should().NotBeNull(); + context.Issuer.Should().Be("https://issuer.test"); + } + [Fact] public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace() { From c32d163837dfff5ad1b7d8754d45499457da98f3 Mon Sep 17 00:00:00 2001 From: Patrick Allwood Date: Thu, 29 Oct 2026 14:35:28 +0000 Subject: [PATCH 3/3] Add copyright header to new file --- .../SessionCleanupServerSideSessionTests.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/SessionCleanupServerSideSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/SessionCleanupServerSideSessionTests.cs index b28d7819e..b5b047035 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/SessionCleanupServerSideSessionTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/SessionCleanupServerSideSessionTests.cs @@ -1,3 +1,6 @@ +// 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;