Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,9 @@ public class EndUserSessionEventContext: UserSessionEventContext
/// Collection of ClientIds active within the session.
/// </summary>
public string[] ClientIds { get; set; } = [];

/// <summary>
/// The issuer of the user's session.
/// </summary>
public string Issuer { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,9 @@ public class LogoutNotificationContext
/// The list of client Ids that the user has authenticated to.
/// </summary>
public IEnumerable<string> ClientIds { get; set; }

/// <summary>
/// The issuer of the user's session.
/// </summary>
public string Issuer { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -152,7 +153,8 @@ protected virtual async Task<string> 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);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
});
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ await backChannelLogoutService.SendLogoutNotificationsAsync(new LogoutNotificati
SubjectId = sessionEventContext.SubjectId,
SessionId = sessionEventContext.SessionId,
ClientIds = sessionEventContext.ClientIds,
Issuer = sessionEventContext.Issuer,
});
}

Expand Down Expand Up @@ -85,6 +86,7 @@ await backChannelLogoutService.SendLogoutNotificationsAsync(new LogoutNotificati
SubjectId = sessionEventContext.SubjectId,
SessionId = sessionEventContext.SessionId,
ClientIds = backChannelClients,
Issuer = sessionEventContext.Issuer,
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ public async Task<IEnumerable<BackChannelLogoutRequest>> GetBackChannelLogoutNot
LogoutUri = client.BackChannelLogoutUri,
SubjectId = context.SubjectId,
SessionId = context.SessionId,
SessionIdRequired = client.BackChannelLogoutSessionRequired
SessionIdRequired = client.BackChannelLogoutSessionRequired,
Issuer = context.Issuer,
};

backChannelLogouts.Add(back);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,9 @@ public class BackChannelLogoutRequest
/// Gets a value indicating whether the session identifier is required.
/// </summary>
public bool SessionIdRequired { get; set; }

/// <summary>
/// Gets or sets the issuer of the user's session.
/// </summary>
public string Issuer { get; set; }
}
Original file line number Diff line number Diff line change
@@ -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<ISessionCleanupService>().RemoveExpiredServerSideSessionsAsync();
}

form.Should().StartWith(OidcConstants.BackChannelLogoutRequest.LogoutToken + "=");
var token = new JwtSecurityTokenHandler().ReadJwtToken(form!.Split('=')[1]);
token.Issuer.Should().Be(IdentityServerPipeline.BaseUrl);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ public DefaultBackChannelLogoutServiceTests()
_tools = new MockIdentityServerTools(Mock.Of<IHttpContextAccessor>(), _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,
Expand All @@ -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<LogoutNotificationContext>()))
.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<IHttpContextAccessor>(), _tokenCreation.Object, _clock));

await subject.SendLogoutNotificationsAsync(new LogoutNotificationContext());

_tokenCreation.Verify(x => x.CreateTokenAsync(It.Is<Token>(t => t.Issuer == "https://issuer.test")), Times.Once);
}
}

internal class MockIdentityServerTools : IdentityServerTools
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<LogoutNotificationContext>(ctx =>
ctx.Issuer == "https://issuer.test")));
}

[Fact]
public async Task RemoveSessionsAsync_WhenRemoveSessionsSetToFalse_ShouldNotRemoveSessions()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<LogoutNotificationContext>(c =>
c.Issuer == "https://issuer.test")));
}

[Theory]
[InlineData("subjectId", null)]
[InlineData("subjectId", "")]
Expand Down Expand Up @@ -423,6 +443,28 @@ public async Task HandleUserSessionExpiry_WhenClientIdNotFound_ShouldExcludeClie
.Verify(x => x.SendLogoutNotificationsAsync(It.IsAny<LogoutNotificationContext>()), 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<LogoutNotificationContext>(c =>
c.Issuer == "https://issuer.test")));
}

[Fact]
private async Task ValidateRefreshTokenAsync_WhenAuthTicketStoreRegistered_ShouldReturnTrue()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<EndUserSessionEventContext>()))
.Callback<EndUserSessionEventContext>(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()
{
Expand Down
Loading