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
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..b5b047035
--- /dev/null
+++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/SessionCleanupServerSideSessionTests.cs
@@ -0,0 +1,47 @@
+// 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;
+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()
{