From f436ff2421bcff76dca4a1da7ba21ac9b52647de Mon Sep 17 00:00:00 2001 From: James Britton Date: Fri, 11 Sep 2026 16:27:14 +0100 Subject: [PATCH 1/8] feat: setup management interfaces, and stub implementation with placeholder tests --- .../Models/Contexts/RemoveSessionsContext.cs | 50 ++++++++ .../src/Models/QueryResult.cs | 50 ++++++++ .../src/Models/SessionQuery.cs | 42 ++++++ .../src/Models/UserSession.cs | 56 ++++++++ .../DefaultSessionManagementService.cs | 34 +++++ .../src/Services/ISessionManagementService.cs | 32 +++++ .../DefaultSessionManagementServiceTests.cs | 121 ++++++++++++++++++ 7 files changed, 385 insertions(+) create mode 100644 src/Open.IdentityServer/src/Models/Contexts/RemoveSessionsContext.cs create mode 100644 src/Open.IdentityServer/src/Models/QueryResult.cs create mode 100644 src/Open.IdentityServer/src/Models/SessionQuery.cs create mode 100644 src/Open.IdentityServer/src/Models/UserSession.cs create mode 100644 src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs create mode 100644 src/Open.IdentityServer/src/Services/ISessionManagementService.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultSessionManagementServiceTests.cs diff --git a/src/Open.IdentityServer/src/Models/Contexts/RemoveSessionsContext.cs b/src/Open.IdentityServer/src/Models/Contexts/RemoveSessionsContext.cs new file mode 100644 index 000000000..cb04830b5 --- /dev/null +++ b/src/Open.IdentityServer/src/Models/Contexts/RemoveSessionsContext.cs @@ -0,0 +1,50 @@ +// 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; + +namespace Open.IdentityServer.Models; + +/// +/// Remove sessions context +/// +public class RemoveSessionsContext +{ + /// + /// Optional subject ID of sessions that should be removed + /// + public string? SubjectId { get; init; } + + /// + /// Optional session ID of the sessions that should be removed + /// + public string? SessionId { get; init; } + + /// + /// Specifies which clients should have their consents and tokens revoked. If null or empty, all clients will have + /// consents and tokens revoked + /// + public IReadOnlyCollection? ClientIds { get; set; } + + /// + /// Specifies if the server-side session should be removed + /// + public bool RemoveServerSideSession { get; set; } = true; + + /// + /// Specifies if back-channel logout notifications should be sent + /// + public bool SendBackchannelLogoutNotification { get; set; } = true; + + /// + /// Specifies if tokens should be revoked for a client + /// + public bool RevokeTokens { get; set; } = true; + + /// + /// Specifies if consents should be revoked for a client + /// + public bool RevokeConsents { get; set; } = true; +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Models/QueryResult.cs b/src/Open.IdentityServer/src/Models/QueryResult.cs new file mode 100644 index 000000000..9ed5691da --- /dev/null +++ b/src/Open.IdentityServer/src/Models/QueryResult.cs @@ -0,0 +1,50 @@ +// 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; + +namespace Open.IdentityServer.Models; + +/// +/// Results from a query request +/// +/// Type of the results being returned +public class QueryResult +{ + /// + /// Token containing information on results. Contains first and last item ids in format 'first,last' + /// + public string? ResultsToken { get; init; } + + /// + /// If false, this is the first page of results; if true, then it is not + /// + public bool HasPrevResults { get; set; } + + /// + /// If false, this is the last page of results; if true then it is not + /// + public bool HasNextResults { get; set; } + + /// + /// Total results for query + /// + public int? TotalCount { get; init; } + + /// + /// Total pages for query + /// + public int? TotalPages { get; init; } + + /// + /// Current number of pages of results + /// + public int? CurrentPage { get; init; } + + /// + /// The results for the current page + /// + public IReadOnlyCollection Results { get; init; } = []; +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Models/SessionQuery.cs b/src/Open.IdentityServer/src/Models/SessionQuery.cs new file mode 100644 index 000000000..f928d3c85 --- /dev/null +++ b/src/Open.IdentityServer/src/Models/SessionQuery.cs @@ -0,0 +1,42 @@ +// 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 + +namespace Open.IdentityServer.Models; + +/// +/// Object containing query information to be applied to session queries +/// +public class SessionQuery +{ + /// + /// Token containing information on previously requested results. Contains first and last item ids in format 'first,last' + /// + public string? ResultsToken { get; set; } + + /// + /// If true, previous results are retrieved; else, next results relative to the results token are retrieved + /// + public bool RequestPriorResults { get; set; } + + /// + /// Number of results requested in response + /// + public int CountRequested { get; set; } + + /// + /// Optional subject identifier used to filter results + /// + public string? SubjectId { get; init; } + + /// + /// Optional session identifier used to filter results + /// + public string? SessionId { get; init; } + + /// + /// Optional display name used to filter results + /// + public string? DisplayName { get; init; } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Models/UserSession.cs b/src/Open.IdentityServer/src/Models/UserSession.cs new file mode 100644 index 000000000..44b759926 --- /dev/null +++ b/src/Open.IdentityServer/src/Models/UserSession.cs @@ -0,0 +1,56 @@ +// 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.Collections.Generic; +using Microsoft.AspNetCore.Authentication; + +namespace Open.IdentityServer.Models; + +/// +/// User session model +/// +public class UserSession +{ + /// + /// Subject ID for the user session + /// + public string SubjectId { get; set; } = null!; + + /// + /// Session ID for the user session + /// + public string SessionId { get; set; } = null!; + + /// + /// Display name for the user session + /// + public string DisplayName { get; set; } = null!; + + /// + /// Date and time the session was created + /// + public DateTime Created { get; set; } + + /// + /// Date and time the session was renewed + /// + public DateTime Renewed { get; set; } + + /// + /// Date and time the session expires, null if no expiry + /// + public DateTime? Expires { get; set; } + + /// + /// Client IDs of clients with active grants and tokens from the session + /// + public IReadOnlyCollection ClientIds { get; set; } = null!; + + /// + /// Authentication ticket object for the user session + /// + public AuthenticationTicket AuthenticationTicket { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs b/src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs new file mode 100644 index 000000000..16118b677 --- /dev/null +++ b/src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs @@ -0,0 +1,34 @@ +// 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; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Open.IdentityServer.Models; +using Open.IdentityServer.Stores; + +namespace Open.IdentityServer.Services; + +/// +/// Default Session management service, has methods for querying sessions and removing them. +/// +public class DefaultSessionManagementService( + IPersistedGrantService persistedGrantService, + IBackChannelLogoutService backChannelLogoutService, + IServerSessionTicketStore serverSessionTicketStore, + ILogger logger): ISessionManagementService +{ + /// + public Task> QuerySessionsAsync(SessionQuery? filter, CancellationToken ct = default) + { + throw new System.NotImplementedException(); + } + + /// + public Task RemoveSessionsAsync(RemoveSessionsContext context, CancellationToken ct = default) + { + throw new System.NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Services/ISessionManagementService.cs b/src/Open.IdentityServer/src/Services/ISessionManagementService.cs new file mode 100644 index 000000000..30a5ffb5c --- /dev/null +++ b/src/Open.IdentityServer/src/Services/ISessionManagementService.cs @@ -0,0 +1,32 @@ +// 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; +using System.Threading.Tasks; +using Open.IdentityServer.Models; + +namespace Open.IdentityServer.Services; + +/// +/// Session management interface, defines methods for querying sessions and removing them. +/// +public interface ISessionManagementService +{ + /// + /// Method for querying sessions + /// + /// filter to be used + /// cancellation token + /// paginated query result of user sessions + Task> QuerySessionsAsync(SessionQuery? filter, CancellationToken ct = default); + + /// + /// Method for removing sessions + /// + /// remove session context + /// cancellation token + /// void + Task RemoveSessionsAsync(RemoveSessionsContext context, CancellationToken ct = default); +} \ No newline at end of file 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 new file mode 100644 index 000000000..108658a1d --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultSessionManagementServiceTests.cs @@ -0,0 +1,121 @@ +// 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 Microsoft.Extensions.Logging; +using Moq; +using Open.IdentityServer.Services; +using Open.IdentityServer.Stores; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Services.Default; + +public class DefaultSessionManagementServiceTests +{ + private IPersistedGrantService persistedGrantService = Mock.Of(); + private IBackChannelLogoutService backChannelLogoutService = Mock.Of(); + private IServerSessionTicketStore serverSessionTicketStore = Mock.Of(); + private ILogger logger = Mock.Of>(); + + private DefaultSessionManagementService CreateSut() => new(persistedGrantService, backChannelLogoutService, serverSessionTicketStore, logger); + + /// TODO: implement query tests, types of query to test + /// 1. When no filter is provided, should use default values + /// 2. When no token is provided, it should get the first page of results + /// 3. When a token is provided, it should get the next page relative to the provided token + /// 4. When a subjectId filter is provided, it should filter the results using it + /// 5. When a sessionId filter is provided, it should filter results using it + /// 6. When a display name filter provided, it should filter results using it + /// 7. + /// + + [Fact] + public async Task QuerySessionsAsync_WhenFilterProvided_ShouldUseDefaultValues() + { + + } + + [Fact] + public async Task QuerySessionsAsync_WhenNoTokenProvided_ShouldProvideFirstPageOfResults() + { + + } + + [Fact] + public async Task QuerySessionsAsync_WhenTokenProvided_ShouldProvideNextPageOfResults() + { + + } + + [Fact] + public async Task QuerySessionsAsync_WhenSubjectIdProvided_ShouldFilterResultsUsingIt() + { + + } + + [Fact] + public async Task QuerySessionsAsync_WhenSessionIdProvided_ShouldFilterResultsUsingIt() + { + + } + + [Fact] + public async Task QuerySessionsAsync_WhenDisplayNameProvided_ShouldFilterResultsUsingIt() + { + + } + + /// TODO: implement removal tests, types of query to tests + /// 1. Remove called with sessionId specified, should remove sessions with specified sessionId + /// 2. Remove called with subjectId specified, should remove sessions with specified subjectId + /// 3. Remove called with clientsIds specified, should only trigger back channel notification and revocations for those clients + /// 4. Remove called with remove sessions set to false, shouldn't remove sessions + /// 5. Remove called with send backchannel set to false, shouldn't send backchannel + /// 6. Remove called with revoke tokens set to false, shouldn't revoke tokens + /// 7. Remove called with revoke consents set to false, shouldn't revoke consents + /// + + [Fact] + public async Task RemoveSessionsAsync_WhenSessionIdSpecified_ShouldRemoveAllSessionsWithThatSessionId() + { + + } + + [Fact] + public async Task RemoveSessionsAsync_WhenSubjectIdSpecified_ShouldRemoveAllSessionsWithThatSubjectId() + { + + } + + [Fact] + public async Task RemoveSessionsAsync_WhenClientIdsProvided_ShouldOnlyTriggerBackchannelNotificationsAndRevocationsForThoseClients() + { + + } + + [Fact] + public async Task RemoveSessionsAsync_WhenRemoveSessionsSetToFalse_ShouldNotRemoveSessions() + { + + } + + [Fact] + public async Task RemoveSessionsAsync_WhenSendBackchannelFalse_ShouldNotSendBackchannelNotification() + { + + } + + [Fact] + public async Task RemoveSessionsAsync_WhenRevokeTokensFalse_ShouldNotRevokeTokens() + { + + } + + [Fact] + public async Task RemoveSessionsAsync_WhenRevokeConsentsFalse_ShouldNotRevokeConsents() + { + + } +} \ No newline at end of file From d65ba1914c700e9a0a426069730e1272f17d3e36 Mon Sep 17 00:00:00 2001 From: James Britton Date: Thu, 17 Sep 2026 11:26:21 +0100 Subject: [PATCH 2/8] feat: implementing paginated query on stores --- .../IdentityServerServerSideSessionStore.cs | 102 ++++++ ...entityServerServerSideSessionStoreTests.cs | 312 +++++++++++++++++- .../Default/ServerSessionTicketStore.cs | 24 ++ .../src/Stores/IServerSessionTicketStore.cs | 13 +- .../Stores/InMemory/InMemorySessionStore.cs | 96 ++++++ .../BuilderExtensions/AdditionalTests.cs | 6 + .../Default/ServerSessionTicketStoreTests.cs | 46 ++- .../Stores/InMemorySessionStoreTests.cs | 265 +++++++++++++++ .../src/Models/QueryResult.cs | 15 + .../src/Models/SessionQuery.cs | 2 +- .../IIdentityServerServerSideSessionStore.cs | 11 +- 11 files changed, 885 insertions(+), 7 deletions(-) rename src/{Open.IdentityServer => Storage}/src/Models/QueryResult.cs (78%) rename src/{Open.IdentityServer => Storage}/src/Models/SessionQuery.cs (96%) diff --git a/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs b/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs index 52034fc0d..06b3aa056 100644 --- a/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs +++ b/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs @@ -5,12 +5,15 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Open.IdentityServer.EntityFramework.Interfaces; using Open.IdentityServer.EntityFramework.Mappers; +using Open.IdentityServer.Models; using Open.IdentityServer.Services; using Open.IdentityServer.Stores; using IdentityServerServerSideSessions = Open.IdentityServer.Models.IdentityServerServerSideSessions; @@ -140,6 +143,105 @@ public async Task> FilterSessions( .Select(x => x.ToModel()); } + /// + public async Task> FilterSessions(SessionQuery? inputQuery, CancellationToken ct = default) + { + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + + SessionQuery query = inputQuery ?? new SessionQuery(); + + IQueryable filteredResults = ApplyFilter(query, dbContext.ServerSideSessions.AsQueryable()); + + int count = await filteredResults.CountAsync(cancellationToken: ct); + + if (count < 1) + { + return new QueryResult + { + TotalCount = count, TotalPages = 0, CurrentPage = 0, HasPrevResults = false, HasNextResults = false, + Results = [], + }; + } + + int totalPages = (count / query.CountRequested) + (count % query.CountRequested != 0 ? 1 : 0); + int currentPage = 1; + + if (!string.IsNullOrWhiteSpace(query.ResultsToken)) + { + (long tokenFirst, long tokenLast) = ParseResultsToken(query); + int elementsBeforeToken = await filteredResults.CountAsync(x => x.Id <= tokenFirst, cancellationToken: ct); + currentPage = 1 + (elementsBeforeToken / query.CountRequested); + + if (query.RequestPriorResults) + { + filteredResults = filteredResults + .Where(x => x.Id >= tokenFirst).Take(query.CountRequested); + } + else + { + currentPage++; + filteredResults = filteredResults + .Where(x => x.Id > tokenLast).Take(query.CountRequested); + } + } + else + { + filteredResults = filteredResults.Take(query.CountRequested); + } + + var results = filteredResults.ToList(); + + return new QueryResult + { + TotalCount = count, + TotalPages = totalPages, + CurrentPage = currentPage, + HasPrevResults = currentPage > 1, + HasNextResults = currentPage < totalPages, + ResultsToken = $"{results.First().Id},{results.Last().Id}", + Results = results.Select(x => x.ToModel()).ToList(), + }; + } + + private (long, long) ParseResultsToken(SessionQuery query) + { + long tokenFirst = 0; + long tokenLast = 0; + + if (query.ResultsToken != null) + { + var split = query.ResultsToken.Split(",", StringSplitOptions.RemoveEmptyEntries); + if (!long.TryParse(split.First(), out tokenFirst) || !long.TryParse(split.Last(), out tokenLast)) + { + logger.LogError("Error occured parsing result token"); + } + } + + return new ValueTuple(tokenFirst, tokenLast); + } + + private IQueryable ApplyFilter(SessionQuery query, + IQueryable input) + { + if (!string.IsNullOrWhiteSpace(query.SubjectId)) + { + input = input + .Where(x => x.SubjectId.Contains(query.SubjectId)); + } + + if (!string.IsNullOrWhiteSpace(query.SessionId)) + { + input = input.Where(x => x.SessionId != null && x.SessionId.Contains(query.SessionId)); + } + + if (!string.IsNullOrWhiteSpace(query.DisplayName)) + { + input = input.Where(x => x.DisplayName != null && x.DisplayName.Contains(query.DisplayName)); + } + + return input.OrderBy(x => x.Id); + } + /// public async Task> GetAndRemoveExpiredSessions(int batchSize = 100) { diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs index 381739c47..7ca625095 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs @@ -8,12 +8,14 @@ using Microsoft.Extensions.Time.Testing; using Moq; using Open.IdentityServer.EntityFramework.DbContexts; -using Open.IdentityServer.EntityFramework.Entities; using Open.IdentityServer.EntityFramework.Options; using Open.IdentityServer.EntityFramework.Stores; +using Open.IdentityServer.Models; using Open.IdentityServer.Services; using Open.IdentityServer.Test.Utilities; using Xunit; +using IdentityServerServerSideSessions = Open.IdentityServer.EntityFramework.Entities.IdentityServerServerSideSessions; +using Range = System.Range; using SessionModel = Open.IdentityServer.Models.IdentityServerServerSideSessions; namespace Open.IdentityServer.EntityFramework.IntegrationTests.Stores.Compatibility; @@ -446,6 +448,312 @@ public async Task GetAndRemoveExpiredSessions_WhenUnspecifiedTimezoneInDbEntitie actual.Should().Contain(x => x.Key == expiredSession0.Key); } + /// TODO: implement filter with query tests, types of query to test + /// 1. When no filter is provided, should use default values + /// 2. When no token is provided, it should get the first page of results + /// 3. When a token is provided, it should get the next page relative to the provided token + /// 4. When a subjectId filter is provided, it should filter the results using it + /// 5. When a sessionId filter is provided, it should filter results using it + /// 6. When a display name filter is provided, it should filter results using it + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FilterSessions_WithQuery_WhenNoResults_ShouldEmptyResultsSet(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + var actual = await sut.FilterSessions(null, TestContext.Current.CancellationToken); + + actual.TotalCount.Should().Be(0); + actual.CurrentPage.Should().Be(0); + actual.TotalPages.Should().Be(0); + actual.ResultsToken.Should().BeNullOrWhiteSpace(); + actual.HasPrevResults.Should().BeFalse(); + actual.HasNextResults.Should().BeFalse(); + actual.Results.Should().BeEmpty(); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FilterSessions_WithQuery_WhenNullQuery_ShouldUseDefaultValues(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + var actual = await sut.FilterSessions(null, TestContext.Current.CancellationToken); + + var sessions = context.ServerSideSessions + .OrderBy(x => x.Id).ToList(); + var expectedToken = $"{sessions.First().Id},{sessions.Last().Id}"; + + actual.TotalCount.Should().Be(7); + actual.CurrentPage.Should().Be(1); + actual.TotalPages.Should().Be(1); + actual.ResultsToken.Should().Be(expectedToken); + actual.HasPrevResults.Should().BeFalse(); + actual.HasNextResults.Should().BeFalse(); + actual.Results.Should().HaveCount(7); + actual.Results.Should().Contain(x => x.Key == "key-0"); + actual.Results.Should().Contain(x => x.Key == "key-1"); + actual.Results.Should().Contain(x => x.Key == "key-2"); + actual.Results.Should().Contain(x => x.Key == "key-3"); + actual.Results.Should().Contain(x => x.Key == "key-4"); + actual.Results.Should().Contain(x => x.Key == "key-5"); + actual.Results.Should().Contain(x => x.Key == "key-6"); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FilterSessions_WithQuery_WhenNoTokenInQuery_ShouldGetFirstPage(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + var actual = await sut.FilterSessions(new SessionQuery + { + CountRequested = 2, + }, TestContext.Current.CancellationToken); + + + var sessions = context.ServerSideSessions + .OrderBy(x => x.Id).Take(2).ToList(); + var expectedToken = $"{sessions.First().Id},{sessions.Last().Id}"; + + actual.TotalCount.Should().Be(7); + actual.CurrentPage.Should().Be(1); + actual.TotalPages.Should().Be(4); + actual.ResultsToken.Should().Be(expectedToken); + actual.HasPrevResults.Should().BeFalse(); + actual.HasNextResults.Should().BeTrue(); + actual.Results.Should().HaveCount(2); + actual.Results.Should().Contain(x => x.Key == "key-0"); + actual.Results.Should().Contain(x => x.Key == "key-1"); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FilterSessions_WithQuery_WhenTokenInQueryAndGetPreviousFalse_ShouldGetNextPage(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + var sessions = context.ServerSideSessions + .OrderBy(x => x.Id).Skip(4).Take(2).ToList(); + var testToken = $"{sessions.First().Id},{sessions.Last().Id}"; + + var actual = await sut.FilterSessions(new SessionQuery + { + ResultsToken = testToken, + RequestPriorResults = false, + CountRequested = 2, + }, TestContext.Current.CancellationToken); + + sessions = context.ServerSideSessions + .OrderBy(x => x.Id).Skip(6).Take(2).ToList(); + var expectedToken = $"{sessions.First().Id},{sessions.Last().Id}"; + + actual.TotalCount.Should().Be(7); + actual.CurrentPage.Should().Be(4); + actual.TotalPages.Should().Be(4); + actual.ResultsToken.Should().Be(expectedToken); + actual.HasPrevResults.Should().BeTrue(); + actual.HasNextResults.Should().BeFalse(); + actual.Results.Should().HaveCount(1); + actual.Results.Should().Contain(x => x.Key == "key-6"); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FilterSessions_WithQuery_WhenTokenInQueryAndGetPreviousTrue_ShouldGetNextPage(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + var sessions = context.ServerSideSessions + .OrderBy(x => x.Id).Skip(4).Take(2).ToList(); + var testToken = $"{sessions.First().Id},{sessions.Last().Id}"; + + var actual = await sut.FilterSessions(new SessionQuery + { + ResultsToken = testToken, + RequestPriorResults = true, + CountRequested = 2, + }, TestContext.Current.CancellationToken); + + actual.TotalCount.Should().Be(7); + actual.CurrentPage.Should().Be(3); + actual.TotalPages.Should().Be(4); + actual.ResultsToken.Should().Be(testToken); + actual.HasPrevResults.Should().BeTrue(); + actual.HasNextResults.Should().BeTrue(); + actual.Results.Should().HaveCount(2); + actual.Results.Should().Contain(x => x.Key == "key-4"); + actual.Results.Should().Contain(x => x.Key == "key-5"); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FilterSessions_WithQuery_WhenSessionIdProvided_ShouldGetFilteredResult(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + var actual = await sut.FilterSessions(new SessionQuery + { + CountRequested = 2, + SessionId = "session-0", + }, TestContext.Current.CancellationToken); + + + var sessions = context.ServerSideSessions + .Where(x => x.SessionId == "session-0") + .OrderBy(x => x.Id) + .Take(2).ToList(); + var expectedToken = $"{sessions.First().Id},{sessions.Last().Id}"; + + actual.TotalCount.Should().Be(2); + actual.CurrentPage.Should().Be(1); + actual.TotalPages.Should().Be(1); + actual.ResultsToken.Should().Be(expectedToken); + actual.HasPrevResults.Should().BeFalse(); + actual.HasNextResults.Should().BeFalse(); + actual.Results.Should().HaveCount(2); + actual.Results.Should().Contain(x => x.Key == "key-0"); + actual.Results.Should().Contain(x => x.Key == "key-4"); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FilterSessions_WithQuery_WhenSubjectIdProvided_ShouldGetFilteredResult(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + var actual = await sut.FilterSessions(new SessionQuery + { + CountRequested = 2, + SubjectId = "bob", + }, TestContext.Current.CancellationToken); + + + var sessions = context.ServerSideSessions + .Where(x => x.SubjectId == "bob") + .OrderBy(x => x.Id) + .Take(2).ToList(); + var expectedToken = $"{sessions.First().Id},{sessions.Last().Id}"; + + actual.TotalCount.Should().Be(4); + actual.CurrentPage.Should().Be(1); + actual.TotalPages.Should().Be(2); + actual.ResultsToken.Should().Be(expectedToken); + actual.HasPrevResults.Should().BeFalse(); + actual.HasNextResults.Should().BeTrue(); + actual.Results.Should().HaveCount(2); + actual.Results.Should().Contain(x => x.Key == "key-0"); + actual.Results.Should().Contain(x => x.Key == "key-2"); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FilterSessions_WithQuery_WhenDisplayNameProvided_ShouldGetFilteredResult(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", DisplayName = "Robert", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", DisplayName = "Laura", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", DisplayName = "Robert", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", DisplayName = "Laura", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", DisplayName = "Robert", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", DisplayName = "Robert", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", DisplayName = "Laura", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + var actual = await sut.FilterSessions(new SessionQuery + { + CountRequested = 2, + DisplayName = "Laura", + }, TestContext.Current.CancellationToken); + + + var sessions = context.ServerSideSessions + .Where(x => x.DisplayName == "Laura") + .OrderBy(x => x.Id) + .Take(2).ToList(); + var expectedToken = $"{sessions.First().Id},{sessions.Last().Id}"; + + actual.TotalCount.Should().Be(3); + actual.CurrentPage.Should().Be(1); + actual.TotalPages.Should().Be(2); + actual.ResultsToken.Should().Be(expectedToken); + actual.HasPrevResults.Should().BeFalse(); + actual.HasNextResults.Should().BeTrue(); + actual.Results.Should().HaveCount(2); + actual.Results.Should().Contain(x => x.Key == "key-1"); + actual.Results.Should().Contain(x => x.Key == "key-3"); + } + [Theory, MemberData(nameof(TestDatabaseProviders))] public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace(DbContextOptions options) { @@ -456,6 +764,7 @@ public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace(DbContextOptions (store => store.UpdateSession(new SessionModel { Key = "FAKE_SESSION_KEY" }), "UpdateSession"), (store => store.DeleteSession("FAKE_SESSION_KEY"), "DeleteSession"), (store => store.FilterSessions("FAKE_SUBJECT_KEY", "FAKE_SESSION_KEY"), "FilterSessions"), + (store => store.FilterSessions(new SessionQuery()), "FilterSessions"), (store => store.GetAndRemoveExpiredSessions(), "GetAndRemoveExpiredSessions"), ]; @@ -484,7 +793,6 @@ public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace(DbContextOptions .Where(m => m.IsPublic && !m.IsStatic && !m.IsSpecialName) .Where(m => m.DeclaringType == typeof(IdentityServerServerSideSessionStore)) .Select(m => m.Name) - .Distinct() .Should().BeEquivalentTo(methods.Select(m => m.traceMethodName)); } diff --git a/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs index 7c5d5ddb9..4d6b4c23e 100644 --- a/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs +++ b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Security.Claims; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; @@ -143,6 +144,29 @@ public async Task> FilterServerAut }).Where(x => x.AuthTicket != null); } + /// + public async Task> FilterServerAuthenticationTickets(SessionQuery? query, CancellationToken ct = default) + { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + + QueryResult sessions = await serverServerSideSessionStore.FilterSessions(query, ct); + + return new QueryResult + { + ResultsToken = sessions.ResultsToken, + HasPrevResults = sessions.HasPrevResults, + HasNextResults = sessions.HasNextResults, + TotalCount = sessions.TotalCount, + TotalPages = sessions.TotalPages, + CurrentPage = sessions.CurrentPage, + Results = sessions.Results.Select(x => new AuthenticationTicketFilterResult + { + Session = x, + AuthTicket = DeserializeAuthTicket(x), + }).Where(x => x.AuthTicket != null).ToList(), + }; + } + /// public async Task> GetAndRemoveExpiredSessions(int batchSize = 100) { diff --git a/src/Open.IdentityServer/src/Stores/IServerSessionTicketStore.cs b/src/Open.IdentityServer/src/Stores/IServerSessionTicketStore.cs index e65f9f8bb..75b5071e1 100644 --- a/src/Open.IdentityServer/src/Stores/IServerSessionTicketStore.cs +++ b/src/Open.IdentityServer/src/Stores/IServerSessionTicketStore.cs @@ -1,7 +1,10 @@ // 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.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; @@ -15,12 +18,20 @@ namespace Open.IdentityServer.Stores; public interface IServerSessionTicketStore: ITicketStore { /// - /// Filters auth tickets stored server side using the provided filters + /// Filters auth tickets stored server-side using the provided filters /// /// subject id filter to apply /// session id filter to apply /// collection of auth ticket matching filter Task> FilterServerAuthenticationTickets(string subjectId, string sessionId); + + /// + /// Filters auth tickets stored server-side using the provided session query object + /// + /// query to applied to server auth tickets + /// cancellation token + /// QueryResult produced using provided query + Task> FilterServerAuthenticationTickets(SessionQuery? query, CancellationToken ct = default); /// /// Removes expired auth tickets and returns a collection of these auth tokens and session objects they come from diff --git a/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs b/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs index da96bbb8f..403a07235 100644 --- a/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs +++ b/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs @@ -7,6 +7,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Open.IdentityServer.Models; @@ -54,6 +55,101 @@ public Task> FilterSessions(string .Where(x => x.SubjectId == subjectId && x.SessionId == sessionId)); } + /// + public async Task> FilterSessions(SessionQuery? inputQuery, CancellationToken ct = default) + { + SessionQuery query = inputQuery ?? new SessionQuery(); + + IQueryable filteredResults = ApplyFilter(query, repo.Values.AsQueryable()); + + int count = filteredResults.Count(); + + if (count < 1) + { + return new QueryResult + { + TotalCount = count, TotalPages = 0, CurrentPage = 0, HasPrevResults = false, HasNextResults = false, + Results = [], + }; + } + + int totalPages = (count / query.CountRequested) + (count % query.CountRequested != 0 ? 1 : 0); + int currentPage = 1; + + if (!string.IsNullOrWhiteSpace(query.ResultsToken)) + { + (string tokenFirst, string tokenLast) = ParseResultsToken(query); + int elementsBeforeToken = filteredResults.Count(x => string.Compare(x.Key, tokenFirst) <= 0); + currentPage = 1 + (elementsBeforeToken / query.CountRequested); + + if (query.RequestPriorResults) + { + filteredResults = filteredResults + .Where(x => string.Compare(x.Key, tokenFirst) >= 0).Take(query.CountRequested); + } + else + { + currentPage++; + filteredResults = filteredResults + .Where(x => string.Compare(x.Key, tokenLast) > 0).Take(query.CountRequested); + } + } + else + { + filteredResults = filteredResults.Take(query.CountRequested); + } + + var results = filteredResults.ToList(); + + return new QueryResult + { + TotalCount = count, + TotalPages = totalPages, + CurrentPage = currentPage, + HasPrevResults = currentPage > 1, + HasNextResults = currentPage < totalPages, + ResultsToken = $"{results.First().Key},{results.Last().Key}", + Results = results.ToList(), + }; + } + + private (string, string) ParseResultsToken(SessionQuery query) + { + string tokenFirst = string.Empty; + string tokenLast = string.Empty; + + if (query.ResultsToken != null) + { + var split = query.ResultsToken.Split(",", StringSplitOptions.RemoveEmptyEntries); + tokenFirst = split.First(); + tokenLast = split.Last(); + } + + return new ValueTuple(tokenFirst, tokenLast); + } + + private IQueryable ApplyFilter(SessionQuery query, + IQueryable input) + { + if (!string.IsNullOrWhiteSpace(query.SubjectId)) + { + input = input + .Where(x => x.SubjectId.Contains(query.SubjectId)); + } + + if (!string.IsNullOrWhiteSpace(query.SessionId)) + { + input = input.Where(x => x.SessionId != null && x.SessionId.Contains(query.SessionId)); + } + + if (!string.IsNullOrWhiteSpace(query.DisplayName)) + { + input = input.Where(x => x.DisplayName != null && x.DisplayName.Contains(query.DisplayName)); + } + + return input.OrderBy(x => x.Key); + } + /// public Task> GetAndRemoveExpiredSessions(int batchSize = 100) { diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/DependencyInjection/BuilderExtensions/AdditionalTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/DependencyInjection/BuilderExtensions/AdditionalTests.cs index 2b49272fa..c765cac87 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/DependencyInjection/BuilderExtensions/AdditionalTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/DependencyInjection/BuilderExtensions/AdditionalTests.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using AwesomeAssertions; using Microsoft.AspNetCore.Authentication.Cookies; @@ -160,6 +161,11 @@ public Task> FilterSessions(string throw new System.NotImplementedException(); } + public Task> FilterSessions(SessionQuery query, CancellationToken ct = default) + { + throw new System.NotImplementedException(); + } + public Task> GetAndRemoveExpiredSessions(int batchSize = 100) { throw new System.NotImplementedException(); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/ServerSessionTicketStoreTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/ServerSessionTicketStoreTests.cs index 411b02786..8bfea201d 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/ServerSessionTicketStoreTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/ServerSessionTicketStoreTests.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Linq; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using AwesomeAssertions; using Microsoft.AspNetCore.Authentication; @@ -44,6 +45,10 @@ public ServerSessionTicketStoreTests() Mock.Get(dataProtectionProvider) .Setup(x => x.CreateProtector(DataProtectionConstants.ServerSideTicketStorePurpose)) .Returns(dataProtector); + + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.FilterSessions(It.IsAny())) + .ReturnsAsync(QueryResult.Empty); } private ServerSessionTicketStore CreateSut() => new(serverServerSideSessionStore, dataProtectionProvider, @@ -512,6 +517,43 @@ private void ValidateAutTicketExists(IEnumerable fakeResult; + IEnumerable sessions = [ + FakeSession(key: "key-0", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), + FakeSession(key: "key-4", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), + FakeSession(key: "key-5", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), + ]; + + List expectedAuthTickets = []; + fakeResult = new QueryResult + { + + Results = sessions.Select(x => GenerateSerialisedData(expectedAuthTickets, x)).ToList(), + }; + + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.FilterSessions(fakeQuery, It.IsAny())) + .ReturnsAsync(fakeResult); + + ServerSessionTicketStore sut = CreateSut(); + QueryResult actual = + (await sut.FilterServerAuthenticationTickets(fakeQuery, TestContext.Current.CancellationToken)); + + actual.Should().NotBeNull(); + actual.Should().BeEquivalentTo(fakeResult, cnf => cnf.Excluding(x => x.Results)); + actual.Results.Should().NotBeNullOrEmpty(); + actual.Results.Should().HaveCount(expectedAuthTickets.Count); + } [Fact] public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace() @@ -526,6 +568,7 @@ public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace() (store => store.RetrieveAsync("FAKE_KEY"), "RetrieveAsync"), (store => store.RemoveAsync("FAKE_KEY"), "RemoveAsync"), (store => store.FilterServerAuthenticationTickets("FAKE_SUB_KEY", "FAKE_SESSION_KEY"), "FilterServerAuthenticationTickets"), + (store => store.FilterServerAuthenticationTickets(new SessionQuery()), "FilterServerAuthenticationTickets"), (store => store.GetAndRemoveExpiredSessions(), "GetAndRemoveExpiredSessions"), ]; @@ -543,7 +586,7 @@ public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace() Mock.Get(telemetry) .Verify(t => t.Trace( - TelemetryConstants.TraceCategories.Stores, sut, method.traceMethodName), Times.Once); + TelemetryConstants.TraceCategories.Stores, sut, method.traceMethodName)); Mock.Get(trace).Verify(t => t.Dispose(), Times.Once); } @@ -552,7 +595,6 @@ public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace() .Where(m => m is { IsPublic: true, IsStatic: false, IsSpecialName: false }) .Where(m => m.DeclaringType == typeof(ServerSessionTicketStore)) .Select(m => m.Name) - .Distinct() .Should().BeEquivalentTo(methods.Select(m => m.traceMethodName)); } diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemorySessionStoreTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemorySessionStoreTests.cs index ab710593e..ec5337b3c 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemorySessionStoreTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemorySessionStoreTests.cs @@ -259,6 +259,271 @@ public async Task FilterSessions_WhenSessionMatch_ShouldReturnMatchingSessions() actual.Should().Contain(x => x.Key == "key-1"); actual.Should().Contain(x => x.Key == "key-6"); } + + /// TODO: implement filter with query tests, types of query to test + /// 1. When no filter is provided, should use default values + /// 2. When no token is provided, it should get the first page of results + /// 3. When a token is provided, it should get the next page relative to the provided token + /// 4. When a subjectId filter is provided, it should filter the results using it + /// 5. When a sessionId filter is provided, it should filter results using it + /// 6. When a display name filter is provided, it should filter results using it + + [Fact] + public async Task FilterSessions_WithQuery_WhenNoResults_ShouldEmptyResultsSet() + { + InMemorySessionStore sut = CreateSut(); + + var actual = await sut.FilterSessions(null, TestContext.Current.CancellationToken); + + actual.TotalCount.Should().Be(0); + actual.CurrentPage.Should().Be(0); + actual.TotalPages.Should().Be(0); + actual.ResultsToken.Should().BeNullOrWhiteSpace(); + actual.HasPrevResults.Should().BeFalse(); + actual.HasNextResults.Should().BeFalse(); + actual.Results.Should().BeEmpty(); + } + + [Fact] + public async Task FilterSessions_WithQuery_WhenNullQuery_ShouldUseDefaultValues() + { + List seededSessions = [ + new() { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new() { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new() { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + + var actual = await sut.FilterSessions(null, TestContext.Current.CancellationToken); + + var expectedToken = $"{seededSessions.ElementAt(0).Key},{seededSessions.ElementAt(6).Key}"; + + actual.TotalCount.Should().Be(7); + actual.CurrentPage.Should().Be(1); + actual.TotalPages.Should().Be(1); + actual.ResultsToken.Should().Be(expectedToken); + actual.HasPrevResults.Should().BeFalse(); + actual.HasNextResults.Should().BeFalse(); + actual.Results.Should().HaveCount(7); + actual.Results.Should().Contain(x => x.Key == "key-0"); + actual.Results.Should().Contain(x => x.Key == "key-1"); + actual.Results.Should().Contain(x => x.Key == "key-2"); + actual.Results.Should().Contain(x => x.Key == "key-3"); + actual.Results.Should().Contain(x => x.Key == "key-4"); + actual.Results.Should().Contain(x => x.Key == "key-5"); + actual.Results.Should().Contain(x => x.Key == "key-6"); + } + + [Fact] + public async Task FilterSessions_WithQuery_WhenNoTokenInQuery_ShouldGetFirstPage() + { + List seededSessions = [ + new() { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new() { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new() { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + + var actual = await sut.FilterSessions(new SessionQuery + { + CountRequested = 2, + }, TestContext.Current.CancellationToken); + + var expectedToken = $"{seededSessions.ElementAt(0).Key},{seededSessions.ElementAt(1).Key}"; + + actual.TotalCount.Should().Be(7); + actual.CurrentPage.Should().Be(1); + actual.TotalPages.Should().Be(4); + actual.ResultsToken.Should().Be(expectedToken); + actual.HasPrevResults.Should().BeFalse(); + actual.HasNextResults.Should().BeTrue(); + actual.Results.Should().HaveCount(2); + actual.Results.Should().Contain(x => x.Key == "key-0"); + actual.Results.Should().Contain(x => x.Key == "key-1"); + } + + [Fact] + public async Task FilterSessions_WithQuery_WhenTokenInQueryAndGetPreviousFalse_ShouldGetNextPage() + { + List seededSessions = [ + new() { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new() { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new() { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + + var testToken = $"{seededSessions.ElementAt(4).Key},{seededSessions.ElementAt(5).Key}"; + + var actual = await sut.FilterSessions(new SessionQuery + { + ResultsToken = testToken, + RequestPriorResults = false, + CountRequested = 2, + }, TestContext.Current.CancellationToken); + + var expectedToken = $"{seededSessions.ElementAt(6).Key},{seededSessions.ElementAt(6).Key}"; + + actual.TotalCount.Should().Be(7); + actual.CurrentPage.Should().Be(4); + actual.TotalPages.Should().Be(4); + actual.ResultsToken.Should().Be(expectedToken); + actual.HasPrevResults.Should().BeTrue(); + actual.HasNextResults.Should().BeFalse(); + actual.Results.Should().HaveCount(1); + actual.Results.Should().Contain(x => x.Key == "key-6"); + } + + [Fact] + public async Task FilterSessions_WithQuery_WhenTokenInQueryAndGetPreviousTrue_ShouldGetNextPage() + { + List seededSessions = [ + new() { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new() { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new() { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + + var testToken = $"{seededSessions.ElementAt(4).Key},{seededSessions.ElementAt(5).Key}"; + + var actual = await sut.FilterSessions(new SessionQuery + { + ResultsToken = testToken, + RequestPriorResults = true, + CountRequested = 2, + }, TestContext.Current.CancellationToken); + + actual.TotalCount.Should().Be(7); + actual.CurrentPage.Should().Be(3); + actual.TotalPages.Should().Be(4); + actual.ResultsToken.Should().Be(testToken); + actual.HasPrevResults.Should().BeTrue(); + actual.HasNextResults.Should().BeTrue(); + actual.Results.Should().HaveCount(2); + actual.Results.Should().Contain(x => x.Key == "key-4"); + actual.Results.Should().Contain(x => x.Key == "key-5"); + } + + [Fact] + public async Task FilterSessions_WithQuery_WhenSessionIdProvided_ShouldGetFilteredResult() + { + List seededSessions = [ + new() { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new() { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new() { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + + var actual = await sut.FilterSessions(new SessionQuery + { + CountRequested = 2, + SessionId = "session-0", + }, TestContext.Current.CancellationToken); + + var expectedToken = $"{seededSessions.ElementAt(0).Key},{seededSessions.ElementAt(4).Key}"; + + actual.TotalCount.Should().Be(2); + actual.CurrentPage.Should().Be(1); + actual.TotalPages.Should().Be(1); + actual.ResultsToken.Should().Be(expectedToken); + actual.HasPrevResults.Should().BeFalse(); + actual.HasNextResults.Should().BeFalse(); + actual.Results.Should().HaveCount(2); + actual.Results.Should().Contain(x => x.Key == "key-0"); + actual.Results.Should().Contain(x => x.Key == "key-4"); + } + + [Fact] + public async Task FilterSessions_WithQuery_WhenSubjectIdProvided_ShouldGetFilteredResult() + { + List seededSessions = [ + new() { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new() { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new() { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + + var actual = await sut.FilterSessions(new SessionQuery + { + CountRequested = 2, + SubjectId = "bob", + }, TestContext.Current.CancellationToken); + + var expectedToken = $"{seededSessions.ElementAt(0).Key},{seededSessions.ElementAt(2).Key}"; + + actual.TotalCount.Should().Be(4); + actual.CurrentPage.Should().Be(1); + actual.TotalPages.Should().Be(2); + actual.ResultsToken.Should().Be(expectedToken); + actual.HasPrevResults.Should().BeFalse(); + actual.HasNextResults.Should().BeTrue(); + actual.Results.Should().HaveCount(2); + actual.Results.Should().Contain(x => x.Key == "key-0"); + actual.Results.Should().Contain(x => x.Key == "key-2"); + } + + [Fact] + public async Task FilterSessions_WithQuery_WhenDisplayNameProvided_ShouldGetFilteredResult() + { + List seededSessions = [ + new() { Key = "key-0", Scheme = "cookie", DisplayName = "Robert", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-1", Scheme = "cookie", DisplayName = "Laura", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new() { Key = "key-2", Scheme = "cookie", DisplayName = "Robert", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-3", Scheme = "cookie", DisplayName = "Laura", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new() { Key = "key-4", Scheme = "cookie", DisplayName = "Robert", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new() { Key = "key-5", Scheme = "cookie", DisplayName = "Robert", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new() { Key = "key-6", Scheme = "cookie", DisplayName = "Laura", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + + var actual = await sut.FilterSessions(new SessionQuery + { + CountRequested = 2, + DisplayName = "Laura", + }, TestContext.Current.CancellationToken); + + var expectedToken = $"{seededSessions.ElementAt(1).Key},{seededSessions.ElementAt(3).Key}"; + + actual.TotalCount.Should().Be(3); + actual.CurrentPage.Should().Be(1); + actual.TotalPages.Should().Be(2); + actual.ResultsToken.Should().Be(expectedToken); + actual.HasPrevResults.Should().BeFalse(); + actual.HasNextResults.Should().BeTrue(); + actual.Results.Should().HaveCount(2); + actual.Results.Should().Contain(x => x.Key == "key-1"); + actual.Results.Should().Contain(x => x.Key == "key-3"); + } [Fact] public async Task GetAndRemoveExpiredSessions_WhenNoExpiredSessionsExist_ShouldRemoveNothingAndReturnEmptyCollection() diff --git a/src/Open.IdentityServer/src/Models/QueryResult.cs b/src/Storage/src/Models/QueryResult.cs similarity index 78% rename from src/Open.IdentityServer/src/Models/QueryResult.cs rename to src/Storage/src/Models/QueryResult.cs index 9ed5691da..f15e71ee4 100644 --- a/src/Open.IdentityServer/src/Models/QueryResult.cs +++ b/src/Storage/src/Models/QueryResult.cs @@ -47,4 +47,19 @@ public class QueryResult /// The results for the current page /// public IReadOnlyCollection Results { get; init; } = []; + + /// + /// Creates an empty instance of + /// + /// + public static QueryResult Empty() => new() + { + ResultsToken = null, + HasPrevResults = false, + HasNextResults = false, + TotalCount = 0, + TotalPages = 0, + CurrentPage = 0, + Results = [], + }; } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Models/SessionQuery.cs b/src/Storage/src/Models/SessionQuery.cs similarity index 96% rename from src/Open.IdentityServer/src/Models/SessionQuery.cs rename to src/Storage/src/Models/SessionQuery.cs index f928d3c85..9dbf93319 100644 --- a/src/Open.IdentityServer/src/Models/SessionQuery.cs +++ b/src/Storage/src/Models/SessionQuery.cs @@ -23,7 +23,7 @@ public class SessionQuery /// /// Number of results requested in response /// - public int CountRequested { get; set; } + public int CountRequested { get; set; } = 25; /// /// Optional subject identifier used to filter results diff --git a/src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs b/src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs index 53280814b..91a20c4d6 100644 --- a/src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs +++ b/src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs @@ -4,6 +4,7 @@ #nullable enable using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using Open.IdentityServer.Models; @@ -43,12 +44,20 @@ public interface IIdentityServerServerSideSessionStore public Task DeleteSession(string key); /// - /// Filters auth tickets stored in server-side sessions using the provided filters + /// Filters server-side sessions using the provided filters /// /// subject id filter to apply /// session id filter to apply /// collection of session entities matching filter public Task> FilterSessions(string subjectId, string sessionId); + + /// + /// Filters server-side sessions using the provided session query object + /// + /// query to applied to server auth tickets + /// cancellation token + /// QueryResult produced using provided query + public Task> FilterSessions(SessionQuery? query, CancellationToken ct = default); /// /// Removes expired sessions and returns a collection of sessions that were removed From 26e6a127326d95fda76b5b1635e108a7b3c00d9a Mon Sep 17 00:00:00 2001 From: James Britton Date: Fri, 18 Sep 2026 14:42:30 +0100 Subject: [PATCH 3/8] feat: setup scaffold for default session management implementation and tests --- .../DefaultSessionManagementService.cs | 6 + .../DefaultSessionManagementServiceTests.cs | 153 ++++++++++++------ 2 files changed, 111 insertions(+), 48 deletions(-) diff --git a/src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs b/src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs index 16118b677..e282509c0 100644 --- a/src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs +++ b/src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs @@ -14,10 +14,16 @@ namespace Open.IdentityServer.Services; /// /// Default Session management service, has methods for querying sessions and removing them. /// +/// +/// +/// +/// +/// public class DefaultSessionManagementService( IPersistedGrantService persistedGrantService, IBackChannelLogoutService backChannelLogoutService, IServerSessionTicketStore serverSessionTicketStore, + ITelemetryService telemetry, ILogger logger): ISessionManagementService { /// 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 108658a1d..95ae3bc1d 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 @@ -3,9 +3,14 @@ #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.Models; using Open.IdentityServer.Services; using Open.IdentityServer.Stores; using Xunit; @@ -17,105 +22,157 @@ public class DefaultSessionManagementServiceTests private IPersistedGrantService persistedGrantService = Mock.Of(); private IBackChannelLogoutService backChannelLogoutService = Mock.Of(); private IServerSessionTicketStore serverSessionTicketStore = Mock.Of(); + private readonly ITelemetryService telemetry = Mock.Of(); private ILogger logger = Mock.Of>(); - - private DefaultSessionManagementService CreateSut() => new(persistedGrantService, backChannelLogoutService, serverSessionTicketStore, logger); - /// TODO: implement query tests, types of query to test - /// 1. When no filter is provided, should use default values - /// 2. When no token is provided, it should get the first page of results - /// 3. When a token is provided, it should get the next page relative to the provided token - /// 4. When a subjectId filter is provided, it should filter the results using it - /// 5. When a sessionId filter is provided, it should filter results using it - /// 6. When a display name filter provided, it should filter results using it - /// 7. - /// - - [Fact] - public async Task QuerySessionsAsync_WhenFilterProvided_ShouldUseDefaultValues() - { - - } - - [Fact] - public async Task QuerySessionsAsync_WhenNoTokenProvided_ShouldProvideFirstPageOfResults() - { - - } - - [Fact] - public async Task QuerySessionsAsync_WhenTokenProvided_ShouldProvideNextPageOfResults() + private readonly QueryResult fakeResult = QueryResult.Empty(); + + public DefaultSessionManagementServiceTests() { - + Mock.Get(serverSessionTicketStore) + .Setup(x => x.FilterServerAuthenticationTickets(It.IsAny())) + .ReturnsAsync(QueryResult.Empty()); } - [Fact] - public async Task QuerySessionsAsync_WhenSubjectIdProvided_ShouldFilterResultsUsingIt() - { - - } + private DefaultSessionManagementService CreateSut() => new(persistedGrantService, backChannelLogoutService, serverSessionTicketStore, telemetry, logger); + + /// TODO: implement query tests, types of query to test + /// 1. Should call the auth ticket store filter method with the provided session query(null) + /// 2. Should call the auth ticket store filter method with the provided session query [Fact] - public async Task QuerySessionsAsync_WhenSessionIdProvided_ShouldFilterResultsUsingIt() + public async Task QuerySessionsAsync_WhenNullFilterProvided_ShouldUseDefaultValues() { - + DefaultSessionManagementService sut = CreateSut(); + + QueryResult actual = await sut.QuerySessionsAsync(null, TestContext.Current.CancellationToken); + + actual.Should().Be(fakeResult); + + Mock.Get(serverSessionTicketStore) + .Verify(x => x.FilterServerAuthenticationTickets(null)); } [Fact] - public async Task QuerySessionsAsync_WhenDisplayNameProvided_ShouldFilterResultsUsingIt() + public async Task QuerySessionsAsync_WhenFilterProvided_ShouldUseDefaultValues() { - + SessionQuery fakeQuery = new SessionQuery(); + DefaultSessionManagementService sut = CreateSut(); + + QueryResult actual = await sut.QuerySessionsAsync(fakeQuery, TestContext.Current.CancellationToken); + + actual.Should().Be(fakeResult); + + Mock.Get(serverSessionTicketStore) + .Verify(x => x.FilterServerAuthenticationTickets(fakeQuery)); } - /// TODO: implement removal tests, types of query to tests - /// 1. Remove called with sessionId specified, should remove sessions with specified sessionId - /// 2. Remove called with subjectId specified, should remove sessions with specified subjectId - /// 3. Remove called with clientsIds specified, should only trigger back channel notification and revocations for those clients + /// TODO: implement removal tests, types of query to test + /// 1. Remove called with sessionId specified, should remove sessions with the specified sessionId + /// 2. Remove called with subjectId specified, should remove sessions with the specified subjectId + /// 3. Remove called with client IDs specified, should only trigger back channel notification and revocations for those clients /// 4. Remove called with remove sessions set to false, shouldn't remove sessions /// 5. Remove called with send backchannel set to false, shouldn't send backchannel /// 6. Remove called with revoke tokens set to false, shouldn't revoke tokens /// 7. Remove called with revoke consents set to false, shouldn't revoke consents - /// [Fact] public async Task RemoveSessionsAsync_WhenSessionIdSpecified_ShouldRemoveAllSessionsWithThatSessionId() { - + RemoveSessionsContext fakeContext = new RemoveSessionsContext(); + DefaultSessionManagementService sut = CreateSut(); + + await sut.RemoveSessionsAsync(fakeContext, TestContext.Current.CancellationToken); } [Fact] public async Task RemoveSessionsAsync_WhenSubjectIdSpecified_ShouldRemoveAllSessionsWithThatSubjectId() { - + RemoveSessionsContext fakeContext = new RemoveSessionsContext(); + DefaultSessionManagementService sut = CreateSut(); + + await sut.RemoveSessionsAsync(fakeContext, TestContext.Current.CancellationToken); } [Fact] public async Task RemoveSessionsAsync_WhenClientIdsProvided_ShouldOnlyTriggerBackchannelNotificationsAndRevocationsForThoseClients() { - + RemoveSessionsContext fakeContext = new RemoveSessionsContext(); + DefaultSessionManagementService sut = CreateSut(); + + await sut.RemoveSessionsAsync(fakeContext, TestContext.Current.CancellationToken); } [Fact] public async Task RemoveSessionsAsync_WhenRemoveSessionsSetToFalse_ShouldNotRemoveSessions() { - + RemoveSessionsContext fakeContext = new RemoveSessionsContext(); + DefaultSessionManagementService sut = CreateSut(); + + await sut.RemoveSessionsAsync(fakeContext, TestContext.Current.CancellationToken); } [Fact] public async Task RemoveSessionsAsync_WhenSendBackchannelFalse_ShouldNotSendBackchannelNotification() { - + RemoveSessionsContext fakeContext = new RemoveSessionsContext(); + DefaultSessionManagementService sut = CreateSut(); + + await sut.RemoveSessionsAsync(fakeContext, TestContext.Current.CancellationToken); } [Fact] public async Task RemoveSessionsAsync_WhenRevokeTokensFalse_ShouldNotRevokeTokens() { - + RemoveSessionsContext fakeContext = new RemoveSessionsContext(); + DefaultSessionManagementService sut = CreateSut(); + + await sut.RemoveSessionsAsync(fakeContext, TestContext.Current.CancellationToken); } [Fact] public async Task RemoveSessionsAsync_WhenRevokeConsentsFalse_ShouldNotRevokeConsents() { - + RemoveSessionsContext fakeContext = new RemoveSessionsContext(); + DefaultSessionManagementService sut = CreateSut(); + + await sut.RemoveSessionsAsync(fakeContext, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace() + { + RemoveSessionsContext fakeRemovalContext = new RemoveSessionsContext(); + + List<(Func actMethod, string traceMethodName)> methods + = [ + (store => store.QuerySessionsAsync(null), "QuerySessionsAsync"), + (store => store.RemoveSessionsAsync(fakeRemovalContext), "RemoveSessionsAsync"), + ]; + + DefaultSessionManagementService sut = CreateSut(); + + foreach ((Func actMethod, string traceMethodName) method in methods) + { + ITrace trace = Mock.Of(); + Mock.Get(telemetry).Setup(t => t.Trace(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(trace); + Mock.Get(trace).Setup(t => t.AddTag(It.IsAny(), It.IsAny())).Returns(trace); + Mock.Get(trace).Setup(t => t.AddTag(It.IsAny(), It.IsAny())).Returns(trace); + + await method.actMethod(sut); + + Mock.Get(telemetry) + .Verify(t => t.Trace( + TelemetryConstants.TraceCategories.Stores, sut, method.traceMethodName)); + Mock.Get(trace).Verify(t => t.Dispose(), Times.Once); + } + + // Assert all methods covered + typeof(DefaultSessionManagementService).GetMethods() + .Where(m => m is { IsPublic: true, IsStatic: false, IsSpecialName: false }) + .Where(m => m.DeclaringType == typeof(DefaultSessionManagementService)) + .Select(m => m.Name) + .Should().BeEquivalentTo(methods.Select(m => m.traceMethodName)); } } \ No newline at end of file From eebaaef6e0aee47fe3a6a1a210ee5be11d891055 Mon Sep 17 00:00:00 2001 From: James Britton Date: Tue, 22 Sep 2026 14:39:08 +0100 Subject: [PATCH 4/8] fix: modified stores to add functionality needed for the session management service --- .../IdentityServerServerSideSessionStore.cs | 31 ++- ...entityServerServerSideSessionStoreTests.cs | 185 ++++++++++++++++++ .../Generators/ServerSessionTestGenerators.cs | 89 +++++++++ .../src/Stores/IServerSessionTicketStore.cs | 4 +- .../Stores/InMemory/InMemorySessionStore.cs | 45 ++++- .../Stores/InMemorySessionStoreTests.cs | 159 +++++++++++++++ .../IIdentityServerServerSideSessionStore.cs | 10 +- 7 files changed, 504 insertions(+), 19 deletions(-) create mode 100644 src/Open.IdentityServer.Test.Utilities/Generators/ServerSessionTestGenerators.cs diff --git a/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs b/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs index 06b3aa056..7449b3dc2 100644 --- a/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs +++ b/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs @@ -130,16 +130,35 @@ public async Task DeleteSession(string key) } /// - public async Task> FilterSessions(string subjectId, string sessionId) + public async Task DeleteSessions(string? subjectId, string? sessionId) { using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); - ArgumentException.ThrowIfNullOrWhiteSpace(subjectId); - ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + if (string.IsNullOrWhiteSpace(subjectId) && string.IsNullOrWhiteSpace(sessionId)) + { + throw new ArgumentException($"{nameof(subjectId)} or {nameof(sessionId)} must have a non null or empty value"); + } + + IQueryable filteredResults = ApplyFilter(new SessionQuery + { + SessionId = sessionId, SubjectId = subjectId, + }, dbContext.ServerSideSessions.AsQueryable()); + + dbContext.ServerSideSessions.RemoveRange(filteredResults); + await dbContext.SaveChangesAsync(); + } + + /// + public async Task> FilterSessions(string? subjectId, string? sessionId) + { + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + + IQueryable filteredResults = ApplyFilter(new SessionQuery + { + SessionId = sessionId, SubjectId = subjectId, + }, dbContext.ServerSideSessions.AsQueryable()); - return (await dbContext.ServerSideSessions - .Where(x => x.SubjectId == subjectId && x.SessionId == sessionId) - .ToListAsync()) + return (await filteredResults.ToListAsync()) .Select(x => x.ToModel()); } diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs index 7ca625095..698ff6f69 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs @@ -307,6 +307,190 @@ public async Task DeleteSession_WhenSessionExistsWithKey_ShouldDeleteStoredSessi stored.Should().BeNull(); } + + [Theory] + [InlineData(null, null)] + [InlineData(null, "")] + [InlineData(null, " ")] + [InlineData("", null)] + [InlineData(" ", null)] + [InlineData("", "")] + [InlineData(" ", " ")] + public async Task DeleteSessions_WhenFiltersNullOrEmpty_ShouldThrowArgumentException(string? subjectId, string? sessionId) + { + await using PersistedGrantDbContext context = await CreateCleanContext(TestDatabaseProviders.FirstOrDefault()); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + Func act = async () => await sut.DeleteSessions(subjectId, sessionId); + + await act.Should().ThrowAsync(); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task DeleteSessions_WhenSubjectIdProvided_ShouldDeleteSessionsWithSubjectId(DbContextOptions options) + { + await using PersistedGrantDbContext context = await CreateCleanContext(TestDatabaseProviders.FirstOrDefault()); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await sut.DeleteSessions("bob",null); + + var currentSessions = context.ServerSideSessions.ToList(); + + currentSessions.Should().HaveCount(3); + currentSessions.Should().Contain(x => x.Key == "key-1"); + currentSessions.Should().Contain(x => x.Key == "key-3"); + currentSessions.Should().Contain(x => x.Key == "key-6"); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task DeleteSessions_WhenSessionIdProvided_ShouldDeleteSessionsWithSessionId(DbContextOptions options) + { + await using PersistedGrantDbContext context = await CreateCleanContext(TestDatabaseProviders.FirstOrDefault()); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await sut.DeleteSessions(null, "session-1"); + + var currentSessions = context.ServerSideSessions.ToList(); + + currentSessions.Should().HaveCount(5); + currentSessions.Should().Contain(x => x.Key == "key-0"); + currentSessions.Should().Contain(x => x.Key == "key-2"); + currentSessions.Should().Contain(x => x.Key == "key-3"); + currentSessions.Should().Contain(x => x.Key == "key-4"); + currentSessions.Should().Contain(x => x.Key == "key-5"); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task DeleteSessions_WhenSubjectIdAndSessionIdProvided_ShouldDeleteSessionsWithSubjectIdAndSessionId(DbContextOptions options) + { + await using PersistedGrantDbContext context = await CreateCleanContext(TestDatabaseProviders.FirstOrDefault()); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await sut.DeleteSessions("bob", "session-2"); + + var currentSessions = context.ServerSideSessions.ToList(); + + currentSessions.Should().HaveCount(5); + currentSessions.Should().Contain(x => x.Key == "key-0"); + currentSessions.Should().Contain(x => x.Key == "key-1"); + currentSessions.Should().Contain(x => x.Key == "key-3"); + currentSessions.Should().Contain(x => x.Key == "key-4"); + currentSessions.Should().Contain(x => x.Key == "key-6"); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FilterSessions_WhenSubjectIdsSessionIsNull_ShouldReturnAll(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + var actual = (await sut.FilterSessions(null, null)).ToList(); + + actual.Should().HaveCount(7); + actual.Should().Contain(x => x.Key == "key-0"); + actual.Should().Contain(x => x.Key == "key-1"); + actual.Should().Contain(x => x.Key == "key-2"); + actual.Should().Contain(x => x.Key == "key-3"); + actual.Should().Contain(x => x.Key == "key-4"); + actual.Should().Contain(x => x.Key == "key-5"); + actual.Should().Contain(x => x.Key == "key-6"); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FilterSessions_WhenSessionIdNull_ShouldReturnMatchingSubjectIds(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + var actual = (await sut.FilterSessions("alice", null)).ToList(); + + actual.Should().HaveCount(3); + actual.Should().Contain(x => x.Key == "key-1"); + actual.Should().Contain(x => x.Key == "key-3"); + actual.Should().Contain(x => x.Key == "key-6"); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FilterSessions_WhenSubjectIdNull_ShouldReturnMatchingSessionIds(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + await context.ServerSideSessions.AddRangeAsync([ + new IdentityServerServerSideSessions { Key = "key-0", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-1", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-2", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-3", Scheme = "cookie", SubjectId = "alice", SessionId = "session-3", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-4", Scheme = "cookie", SubjectId = "bob", SessionId = "session-0", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-5", Scheme = "cookie", SubjectId = "bob", SessionId = "session-2", Data = "{\"delete\":true}" }, + new IdentityServerServerSideSessions { Key = "key-6", Scheme = "cookie", SubjectId = "alice", SessionId = "session-1", Data = "{\"delete\":true}" }, + ]); + await context.SaveChangesAsync(); + + var actual = (await sut.FilterSessions(null,"session-0")).ToList(); + + actual.Should().HaveCount(2); + actual.Should().Contain(x => x.Key == "key-0"); + actual.Should().Contain(x => x.Key == "key-4"); + } [Theory, MemberData(nameof(TestDatabaseProviders))] public async Task FilterSessions_WhenSessionDontMatch_ShouldReturnEmptySet(DbContextOptions options) @@ -763,6 +947,7 @@ public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace(DbContextOptions (store => store.GetSession("FAKE_SESSION_KEY"), "GetSession"), (store => store.UpdateSession(new SessionModel { Key = "FAKE_SESSION_KEY" }), "UpdateSession"), (store => store.DeleteSession("FAKE_SESSION_KEY"), "DeleteSession"), + (store => store.DeleteSessions("FAKE_SESSION_KEY", "FAKE_SUBJECT_KEY"), "DeleteSessions"), (store => store.FilterSessions("FAKE_SUBJECT_KEY", "FAKE_SESSION_KEY"), "FilterSessions"), (store => store.FilterSessions(new SessionQuery()), "FilterSessions"), (store => store.GetAndRemoveExpiredSessions(), "GetAndRemoveExpiredSessions"), diff --git a/src/Open.IdentityServer.Test.Utilities/Generators/ServerSessionTestGenerators.cs b/src/Open.IdentityServer.Test.Utilities/Generators/ServerSessionTestGenerators.cs new file mode 100644 index 000000000..3d54813a0 --- /dev/null +++ b/src/Open.IdentityServer.Test.Utilities/Generators/ServerSessionTestGenerators.cs @@ -0,0 +1,89 @@ +using Microsoft.AspNetCore.Authentication; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.Models; +using Open.IdentityServer.Stores.Serialization; + +namespace Open.IdentityServer.Test.Utilities.Generators; + +public static class AuthenticationTicketGenerators +{ + public static AuthenticationTicket GenerateAuthenticationTicket(string authScheme, string? subjectId, string? sessionId, + string? displayName = null, DateTimeOffset? issuedUtc = null, DateTimeOffset? expiresUtc = null) + { + IdentityServerUser user = new(subjectId); + AuthenticationProperties properties = new(); + + properties.SetSessionId(sessionId); + + user.DisplayName = displayName; + properties.IssuedUtc = issuedUtc; + properties.ExpiresUtc = expiresUtc; + + return new AuthenticationTicket(user.CreatePrincipal(), properties, authScheme); + } + + public static SerializedAuthenticationTicket GenerateSerializedAuthenticationTicket(string authScheme, string? subjectId, + string? sessionId, string? displayName = null, DateTimeOffset? issuedUtc = null, + DateTimeOffset? expiresUtc = null) + { + List claims = []; + + if (subjectId != null) + { + claims.Add(new ClaimLite { Type = "sub", Value = subjectId, ValueType = "", Issuer = "", }); + } + + if (displayName != null) + { + claims.Add(new ClaimLite { Type = "name", Value = displayName, ValueType = "", Issuer = "", }); + } + + var items = new Dictionary(); + + if (sessionId != null) + { + items["session_id"] = sessionId; + } + + if (issuedUtc != null) + { + items[".issued"] = issuedUtc.Value.ToString("R"); + } + + if (expiresUtc != null) + { + items[".expires"] = expiresUtc.Value.ToString("R"); + } + + return new SerializedAuthenticationTicket + { + Scheme = authScheme, + User = new ClaimsPrincipalLite + { + AuthenticationType = "Open.IdentityServer", + Claims = claims.ToArray(), + }, + Items = items, + }; + } + + public static IdentityServerServerSideSessions FakeSession( + string key, + string scheme, + string sessionId, + string subjectId, + string displayName, + string? data = null, + DateTime? created = null, + DateTime? renewed = null, + DateTime? expires = null) + { + return new IdentityServerServerSideSessions + { + Key = key, Scheme = scheme, SessionId = sessionId, SubjectId = subjectId, DisplayName = displayName, Data = data ?? string.Empty, + Created = created ?? new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc), + Renewed = renewed ?? new DateTime(2026, 1, 2, 12, 0, 0, DateTimeKind.Utc), + Expires = expires ?? new DateTime(2026, 1, 31, 12, 0, 0, DateTimeKind.Utc), + }; + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Stores/IServerSessionTicketStore.cs b/src/Open.IdentityServer/src/Stores/IServerSessionTicketStore.cs index 75b5071e1..ff9562820 100644 --- a/src/Open.IdentityServer/src/Stores/IServerSessionTicketStore.cs +++ b/src/Open.IdentityServer/src/Stores/IServerSessionTicketStore.cs @@ -23,7 +23,7 @@ public interface IServerSessionTicketStore: ITicketStore /// subject id filter to apply /// session id filter to apply /// collection of auth ticket matching filter - Task> FilterServerAuthenticationTickets(string subjectId, string sessionId); + Task> FilterServerAuthenticationTickets(string? subjectId, string? sessionId); /// /// Filters auth tickets stored server-side using the provided session query object @@ -34,7 +34,7 @@ public interface IServerSessionTicketStore: ITicketStore Task> FilterServerAuthenticationTickets(SessionQuery? query, CancellationToken ct = default); /// - /// Removes expired auth tickets and returns a collection of these auth tokens and session objects they come from + /// Removes expired auth tickets and returns a collection of these auth tokens and the session objects they come from /// /// optional batch size value, defaults to 100 /// removed expired sessions diff --git a/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs b/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs index 403a07235..a5341bcf8 100644 --- a/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs +++ b/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs @@ -49,10 +49,35 @@ public Task DeleteSession(string key) } /// - public Task> FilterSessions(string subjectId, string sessionId) + public Task DeleteSessions(string? subjectId, string? sessionId) { - return Task.FromResult(repo.Values - .Where(x => x.SubjectId == subjectId && x.SessionId == sessionId)); + if (string.IsNullOrWhiteSpace(subjectId) && string.IsNullOrWhiteSpace(sessionId)) + { + throw new ArgumentException($"{nameof(subjectId)} or {nameof(sessionId)} must have a non null or empty value"); + } + + IEnumerable filteredResults = ApplyFilter(new SessionQuery + { + SubjectId = subjectId, SessionId = sessionId, + }, repo.Values); + + foreach (var filteredResult in filteredResults) + { + repo.TryRemove(filteredResult.Key, out _); + } + + return Task.CompletedTask; + } + + /// + public Task> FilterSessions(string? subjectId, string? sessionId) + { + IEnumerable filteredResults = ApplyFilter(new SessionQuery + { + SubjectId = subjectId, SessionId = sessionId, + }, repo.Values); + + return Task.FromResult(filteredResults); } /// @@ -60,8 +85,8 @@ public async Task> FilterSessions( { SessionQuery query = inputQuery ?? new SessionQuery(); - IQueryable filteredResults = ApplyFilter(query, repo.Values.AsQueryable()); - + IEnumerable filteredResults = ApplyFilter(query, repo.Values).ToList(); + int count = filteredResults.Count(); if (count < 1) @@ -79,19 +104,19 @@ public async Task> FilterSessions( if (!string.IsNullOrWhiteSpace(query.ResultsToken)) { (string tokenFirst, string tokenLast) = ParseResultsToken(query); - int elementsBeforeToken = filteredResults.Count(x => string.Compare(x.Key, tokenFirst) <= 0); + int elementsBeforeToken = filteredResults.Count(x => string.CompareOrdinal(x.Key, tokenFirst) <= 0); currentPage = 1 + (elementsBeforeToken / query.CountRequested); if (query.RequestPriorResults) { filteredResults = filteredResults - .Where(x => string.Compare(x.Key, tokenFirst) >= 0).Take(query.CountRequested); + .Where(x => string.CompareOrdinal(x.Key, tokenFirst) >= 0).Take(query.CountRequested); } else { currentPage++; filteredResults = filteredResults - .Where(x => string.Compare(x.Key, tokenLast) > 0).Take(query.CountRequested); + .Where(x => string.CompareOrdinal(x.Key, tokenLast) > 0).Take(query.CountRequested); } } else @@ -128,8 +153,8 @@ public async Task> FilterSessions( return new ValueTuple(tokenFirst, tokenLast); } - private IQueryable ApplyFilter(SessionQuery query, - IQueryable input) + private IEnumerable ApplyFilter(SessionQuery query, + IEnumerable input) { if (!string.IsNullOrWhiteSpace(query.SubjectId)) { diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemorySessionStoreTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemorySessionStoreTests.cs index ec5337b3c..838453365 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemorySessionStoreTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemorySessionStoreTests.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.InteropServices; using System.Threading.Tasks; using AwesomeAssertions; using Open.IdentityServer.Models; @@ -207,6 +208,92 @@ public async Task DeleteSession_WhenSessionExists_ShouldBeRemoved() IdentityServerServerSideSessions? actual = await sut.GetSession(testKey); actual.Should().BeNull(); } + + [Theory] + [InlineData(null, null)] + [InlineData(null, "")] + [InlineData(null, " ")] + [InlineData("", null)] + [InlineData(" ", null)] + [InlineData("", "")] + [InlineData(" ", " ")] + public async Task DeleteSessions_WhenFiltersNullOrEmpty_ShouldThrowArgumentException(string? subjectId, string? sessionId) + { + InMemorySessionStore sut = CreateSut(); + + Func act = async () => await sut.DeleteSessions(subjectId, sessionId); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DeleteSessions_WhenSubjectIdProvided_ShouldDeleteSessionsWithSubjectId() + { + var testSessionKey1 = "session-0"; + var testSessionKey2 = "session-3"; + + IEnumerable seededSessions = [ + new() { Key = "session-0", DisplayName = "Session 0", SessionId = Guid.NewGuid().ToString(), SubjectId = "bob" }, + new() { Key = "session-1", DisplayName = "Session 1", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + new() { Key = "session-2", DisplayName = "Session 2", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + new() { Key = "session-3", DisplayName = "Session 3", SessionId = Guid.NewGuid().ToString(), SubjectId = "bob" }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + (await sut.GetSession(testSessionKey1)).Should().NotBeNull(); + (await sut.GetSession(testSessionKey2)).Should().NotBeNull(); + + await sut.DeleteSessions("bob",null); + + (await sut.GetSession(testSessionKey1)).Should().BeNull(); + (await sut.GetSession(testSessionKey2)).Should().BeNull(); + } + + [Fact] + public async Task DeleteSessions_WhenSessionIdProvided_ShouldDeleteSessionsWithSessionId() + { + var testSessionKey1 = "session-1"; + var testSessionKey2 = "session-2"; + + IEnumerable seededSessions = [ + new() { Key = "session-0", DisplayName = "Session 0", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + new() { Key = "session-1", DisplayName = "Session 1", SessionId = "sessionA", SubjectId = Guid.NewGuid().ToString() }, + new() { Key = "session-2", DisplayName = "Session 2", SessionId = "sessionA", SubjectId = Guid.NewGuid().ToString() }, + new() { Key = "session-3", DisplayName = "Session 3", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + (await sut.GetSession(testSessionKey1)).Should().NotBeNull(); + (await sut.GetSession(testSessionKey2)).Should().NotBeNull(); + + await sut.DeleteSessions(null, "sessionA"); + + (await sut.GetSession(testSessionKey1)).Should().BeNull(); + (await sut.GetSession(testSessionKey2)).Should().BeNull(); + } + + [Fact] + public async Task DeleteSessions_WhenSubjectIdAndSessionIdProvided_ShouldDeleteSessionsWithSubjectIdAndSessionId() + { + var testSessionKey1 = "session-1"; + var testSessionKey2 = "session-3"; + + IEnumerable seededSessions = [ + new() { Key = "session-0", DisplayName = "Session 0", SessionId = Guid.NewGuid().ToString(), SubjectId = "bob" }, + new() { Key = "session-1", DisplayName = "Session 1", SessionId = "sessionA", SubjectId = "bob" }, + new() { Key = "session-2", DisplayName = "Session 2", SessionId = "sessionA", SubjectId = Guid.NewGuid().ToString() }, + new() { Key = "session-3", DisplayName = "Session 3", SessionId = "sessionA", SubjectId = "bob" }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + (await sut.GetSession(testSessionKey1)).Should().NotBeNull(); + (await sut.GetSession(testSessionKey2)).Should().NotBeNull(); + + await sut.DeleteSessions("bob", "sessionA"); + + (await sut.GetSession(testSessionKey1)).Should().BeNull(); + (await sut.GetSession(testSessionKey2)).Should().BeNull(); + } [Fact] public async Task FilterSessions_WhenSessionDontMatch_ShouldReturnEmptySet() @@ -238,6 +325,78 @@ public async Task FilterSessions_WhenNoSessionsStored_ShouldReturnEmptySet() actual.Should().BeEmpty(); } + [Fact] + public async Task FilterSessions_WhenSessionIdAndSubjectIdNull_ShouldReturnAll() + { + IEnumerable seededSessions = [ + new() { Key = "key-0", SubjectId = "bob", SessionId = "session-0" }, + new() { Key = "key-1", SubjectId = "alice", SessionId = "session-1" }, + new() { Key = "key-2", SubjectId = "bob", SessionId = "session-2" }, + new() { Key = "key-3", SubjectId = "alice", SessionId = "session-3" }, + new() { Key = "key-4", SubjectId = "bob", SessionId = "session-0" }, + new() { Key = "key-5", SubjectId = "bob", SessionId = "session-2" }, + new() { Key = "key-6", SubjectId = "alice", SessionId = "session-1" }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + + var actual = (await sut.FilterSessions(null, null)).ToList(); + + actual.Should().HaveCount(7); + actual.Should().Contain(x => x.Key == "key-0"); + actual.Should().Contain(x => x.Key == "key-1"); + actual.Should().Contain(x => x.Key == "key-2"); + actual.Should().Contain(x => x.Key == "key-3"); + actual.Should().Contain(x => x.Key == "key-4"); + actual.Should().Contain(x => x.Key == "key-5"); + actual.Should().Contain(x => x.Key == "key-6"); + } + + [Fact] + public async Task FilterSessions_WhenSessionIdNull_ShouldReturnMatchingSubjectIdOnly() + { + IEnumerable seededSessions = [ + new() { Key = "key-0", SubjectId = "bob", SessionId = "session-0" }, + new() { Key = "key-1", SubjectId = "alice", SessionId = "session-1" }, + new() { Key = "key-2", SubjectId = "bob", SessionId = "session-2" }, + new() { Key = "key-3", SubjectId = "alice", SessionId = "session-3" }, + new() { Key = "key-4", SubjectId = "bob", SessionId = "session-0" }, + new() { Key = "key-5", SubjectId = "bob", SessionId = "session-2" }, + new() { Key = "key-6", SubjectId = "alice", SessionId = "session-1" }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + + var actual = (await sut.FilterSessions("alice", null)).ToList(); + + actual.Should().HaveCount(3); + actual.Should().Contain(x => x.Key == "key-1"); + actual.Should().Contain(x => x.Key == "key-3"); + actual.Should().Contain(x => x.Key == "key-6"); + } + + [Fact] + public async Task FilterSessions_WhenSubjectIdNull_ShouldReturnMatchingSessionIdOnly() + { + IEnumerable seededSessions = [ + new() { Key = "key-0", SubjectId = "bob", SessionId = "session-0" }, + new() { Key = "key-1", SubjectId = "alice", SessionId = "session-1" }, + new() { Key = "key-2", SubjectId = "bob", SessionId = "session-2" }, + new() { Key = "key-3", SubjectId = "alice", SessionId = "session-3" }, + new() { Key = "key-4", SubjectId = "bob", SessionId = "session-0" }, + new() { Key = "key-5", SubjectId = "bob", SessionId = "session-2" }, + new() { Key = "key-6", SubjectId = "alice", SessionId = "session-1" }, + ]; + + InMemorySessionStore sut = CreateSut(seededSessions); + + var actual = (await sut.FilterSessions(null, "session-1")).ToList(); + + actual.Should().HaveCount(2); + actual.Should().Contain(x => x.Key == "key-1"); + actual.Should().Contain(x => x.Key == "key-6"); + } + [Fact] public async Task FilterSessions_WhenSessionMatch_ShouldReturnMatchingSessions() { diff --git a/src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs b/src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs index 91a20c4d6..d19a8923c 100644 --- a/src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs +++ b/src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs @@ -42,6 +42,14 @@ public interface IIdentityServerServerSideSessionStore /// unique key of session /// void public Task DeleteSession(string key); + + /// + /// Deletes server-side session using unique key + /// + /// subject id filter to apply + /// session id filter to apply + /// void + public Task DeleteSessions(string? subjectId, string? sessionId); /// /// Filters server-side sessions using the provided filters @@ -49,7 +57,7 @@ public interface IIdentityServerServerSideSessionStore /// subject id filter to apply /// session id filter to apply /// collection of session entities matching filter - public Task> FilterSessions(string subjectId, string sessionId); + public Task> FilterSessions(string? subjectId, string? sessionId); /// /// Filters server-side sessions using the provided session query object From bf9f3e15600d98ad8736b979ae7b3f4f9c3bebcc Mon Sep 17 00:00:00 2001 From: James Britton Date: Wed, 23 Sep 2026 00:54:32 +0100 Subject: [PATCH 5/8] feat: implementing session management default implementation --- .../Open.IdentityServer.Test.Utilities.csproj | 4 + .../AuthenticationTicketFilterResult.cs | 21 + .../src/Models/UserSession.cs | 4 +- .../DefaultSessionManagementService.cs | 82 +++- .../Default/ServerSessionTicketStore.cs | 23 +- .../BuilderExtensions/AdditionalTests.cs | 5 + .../Open.IdentityServer.UnitTests.csproj | 4 + .../DefaultSessionManagementServiceTests.cs | 367 ++++++++++++++++-- .../Default/ServerSessionTicketStoreTests.cs | 121 +----- .../Generators/ServerSessionTestGenerators.cs | 32 +- src/Storage/src/Models/QueryResult.cs | 22 ++ 11 files changed, 511 insertions(+), 174 deletions(-) rename src/{Open.IdentityServer.Test.Utilities => Open.IdentityServer/test/Open.IdentityServer.UnitTests/Utilities}/Generators/ServerSessionTestGenerators.cs (74%) diff --git a/src/Open.IdentityServer.Test.Utilities/Open.IdentityServer.Test.Utilities.csproj b/src/Open.IdentityServer.Test.Utilities/Open.IdentityServer.Test.Utilities.csproj index 237d66167..23677b054 100644 --- a/src/Open.IdentityServer.Test.Utilities/Open.IdentityServer.Test.Utilities.csproj +++ b/src/Open.IdentityServer.Test.Utilities/Open.IdentityServer.Test.Utilities.csproj @@ -5,5 +5,9 @@ enable enable + + + + diff --git a/src/Open.IdentityServer/src/Models/AuthenticationTicketFilterResult.cs b/src/Open.IdentityServer/src/Models/AuthenticationTicketFilterResult.cs index 5b9a9c4c1..f5f2e335b 100644 --- a/src/Open.IdentityServer/src/Models/AuthenticationTicketFilterResult.cs +++ b/src/Open.IdentityServer/src/Models/AuthenticationTicketFilterResult.cs @@ -3,7 +3,9 @@ #nullable enable +using System.Linq; using Microsoft.AspNetCore.Authentication; +using Open.IdentityServer.Extensions; using Open.IdentityServer.Stores; namespace Open.IdentityServer.Models; @@ -22,4 +24,23 @@ public class AuthenticationTicketFilterResult /// AuthenticationTicket deserialized from the data property on the session entity /// public AuthenticationTicket? AuthTicket { get; set; } + + /// + /// Maps object to an instance of the model + /// + /// new object + public UserSession ToUserSession() + { + return new UserSession + { + SubjectId = Session.SubjectId, + SessionId = Session.SessionId, + DisplayName = Session.DisplayName, + Created = Session.Created, + Renewed = Session.Renewed, + Expires = Session.Expires, + ClientIds = AuthTicket?.Properties.GetClientList().ToList() ?? [], + AuthenticationTicket = AuthTicket, + }; + } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Models/UserSession.cs b/src/Open.IdentityServer/src/Models/UserSession.cs index 44b759926..c95712c0e 100644 --- a/src/Open.IdentityServer/src/Models/UserSession.cs +++ b/src/Open.IdentityServer/src/Models/UserSession.cs @@ -27,7 +27,7 @@ public class UserSession /// /// Display name for the user session /// - public string DisplayName { get; set; } = null!; + public string? DisplayName { get; set; } /// /// Date and time the session was created @@ -52,5 +52,5 @@ public class UserSession /// /// Authentication ticket object for the user session /// - public AuthenticationTicket AuthenticationTicket { get; set; } = null!; + public AuthenticationTicket? AuthenticationTicket { get; set; } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs b/src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs index e282509c0..25531a3c1 100644 --- a/src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs +++ b/src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs @@ -3,9 +3,11 @@ #nullable enable +using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.Logging; +using Open.IdentityServer.Extensions; using Open.IdentityServer.Models; using Open.IdentityServer.Stores; @@ -14,27 +16,81 @@ namespace Open.IdentityServer.Services; /// /// Default Session management service, has methods for querying sessions and removing them. /// -/// -/// -/// -/// -/// +/// persisted grant store +/// back channel logout service +/// auth ticket store +/// server session store +/// telemetry service public class DefaultSessionManagementService( - IPersistedGrantService persistedGrantService, + IPersistedGrantStore persistedGrantStore, IBackChannelLogoutService backChannelLogoutService, IServerSessionTicketStore serverSessionTicketStore, - ITelemetryService telemetry, - ILogger logger): ISessionManagementService + IIdentityServerServerSideSessionStore serverSessionStore, + ITelemetryService telemetry): ISessionManagementService { /// - public Task> QuerySessionsAsync(SessionQuery? filter, CancellationToken ct = default) + public async Task> QuerySessionsAsync(SessionQuery? filter, CancellationToken ct = default) { - throw new System.NotImplementedException(); + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Services, this); + + QueryResult results = await serverSessionTicketStore.FilterServerAuthenticationTickets(filter, ct); + + return results.MapTo(x => x.ToUserSession()); } /// - public Task RemoveSessionsAsync(RemoveSessionsContext context, CancellationToken ct = default) + public async Task RemoveSessionsAsync(RemoveSessionsContext context, CancellationToken ct = default) { - throw new System.NotImplementedException(); + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Services, this); + + if (context.SendBackchannelLogoutNotification) + { + var sessions = await serverSessionTicketStore.FilterServerAuthenticationTickets(context.SubjectId, context.SessionId); + foreach (var sess in sessions) + { + List? sessionClientList = sess.AuthTicket?.Properties.GetClientList().ToList(); + string[] clientIds = []; + + if (!sessionClientList.IsNullOrEmpty() && !context.ClientIds.IsNullOrEmpty()) + { + clientIds = sessionClientList!.Where(x => context.ClientIds!.Contains(x)).ToArray(); + } + + await backChannelLogoutService.SendLogoutNotificationsAsync(new LogoutNotificationContext + { + SubjectId = sess.Session.SubjectId, + SessionId = sess.Session.SessionId, + ClientIds = clientIds, + }); + } + } + + if (context.RevokeTokens || context.RevokeConsents) + { + List typeFilter = []; + + if (context.RevokeTokens) + { + typeFilter.AddRange(IdentityServerConstants.PersistedGrantTypes.PersistedGrantTokenTypes); + } + + if (context.RevokeConsents) + { + typeFilter.Add(IdentityServerConstants.PersistedGrantTypes.UserConsent); + } + + await persistedGrantStore.RemoveAllAsync(new PersistedGrantFilter + { + SubjectId = context.SubjectId, + SessionId = context.SessionId, + ClientIds = context.ClientIds?.ToArray() ?? [], + Types = typeFilter.ToArray(), + }); + } + + if (context.RemoveServerSideSession) + { + await serverSessionStore.DeleteSessions(context.SubjectId, context.SessionId); + } } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs index 4d6b4c23e..b3d8dccae 100644 --- a/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs +++ b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs @@ -150,21 +150,12 @@ public async Task> FilterServerAut using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); QueryResult sessions = await serverServerSideSessionStore.FilterSessions(query, ct); - - return new QueryResult + + return sessions.MapTo(x => new AuthenticationTicketFilterResult { - ResultsToken = sessions.ResultsToken, - HasPrevResults = sessions.HasPrevResults, - HasNextResults = sessions.HasNextResults, - TotalCount = sessions.TotalCount, - TotalPages = sessions.TotalPages, - CurrentPage = sessions.CurrentPage, - Results = sessions.Results.Select(x => new AuthenticationTicketFilterResult - { - Session = x, - AuthTicket = DeserializeAuthTicket(x), - }).Where(x => x.AuthTicket != null).ToList(), - }; + Session = x, + AuthTicket = DeserializeAuthTicket(x), + }); } /// @@ -178,7 +169,7 @@ public async Task> GetAndRemoveExp { Session = x, AuthTicket = DeserializeAuthTicket(x), - }).Where(x => x.AuthTicket != null); + }); } private async Task StoreNewSession(string key, AuthenticationTicket ticket) @@ -221,7 +212,7 @@ private string ToProtectedDataString(AuthenticationTicket ticket) } catch (JsonException exception) { - logger.LogError(exception, "failed deserialising auth ticket data"); + logger.LogError(exception, "failed deserialising auth ticket data '{SessionKey}'", existingSession.Key); return null; } diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/DependencyInjection/BuilderExtensions/AdditionalTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/DependencyInjection/BuilderExtensions/AdditionalTests.cs index c765cac87..1ee8de94d 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/DependencyInjection/BuilderExtensions/AdditionalTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/DependencyInjection/BuilderExtensions/AdditionalTests.cs @@ -156,6 +156,11 @@ public Task DeleteSession(string key) throw new System.NotImplementedException(); } + public Task DeleteSessions(string subjectId, string sessionId) + { + throw new System.NotImplementedException(); + } + public Task> FilterSessions(string subjectId, string sessionId) { throw new System.NotImplementedException(); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Open.IdentityServer.UnitTests.csproj b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Open.IdentityServer.UnitTests.csproj index ba8a01d45..f2b0e1f3c 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Open.IdentityServer.UnitTests.csproj +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Open.IdentityServer.UnitTests.csproj @@ -45,4 +45,8 @@ MockLogger.cs + + + + 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 95ae3bc1d..f09f0ae38 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 @@ -6,39 +6,37 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using AwesomeAssertions; -using Microsoft.Extensions.Logging; using Moq; +using Open.IdentityServer.Extensions; using Open.IdentityServer.Models; using Open.IdentityServer.Services; using Open.IdentityServer.Stores; +using Open.IdentityServer.UnitTests.Utilities.Generators; using Xunit; namespace Open.IdentityServer.UnitTests.Services.Default; public class DefaultSessionManagementServiceTests { - private IPersistedGrantService persistedGrantService = Mock.Of(); - private IBackChannelLogoutService backChannelLogoutService = Mock.Of(); - private IServerSessionTicketStore serverSessionTicketStore = Mock.Of(); + private readonly IPersistedGrantStore persistedGrantStore = Mock.Of(); + private readonly IBackChannelLogoutService backChannelLogoutService = Mock.Of(); + private readonly IServerSessionTicketStore serverSessionTicketStore = Mock.Of(); + private readonly IIdentityServerServerSideSessionStore serverSessionStore = Mock.Of(); private readonly ITelemetryService telemetry = Mock.Of(); - private ILogger logger = Mock.Of>(); - private readonly QueryResult fakeResult = QueryResult.Empty(); + private static readonly QueryResult FakeResult = QueryResult.Empty(); public DefaultSessionManagementServiceTests() { Mock.Get(serverSessionTicketStore) - .Setup(x => x.FilterServerAuthenticationTickets(It.IsAny())) + .Setup(x => x.FilterServerAuthenticationTickets(It.IsAny(), It.IsAny())) .ReturnsAsync(QueryResult.Empty()); } - private DefaultSessionManagementService CreateSut() => new(persistedGrantService, backChannelLogoutService, serverSessionTicketStore, telemetry, logger); - - /// TODO: implement query tests, types of query to test - /// 1. Should call the auth ticket store filter method with the provided session query(null) - /// 2. Should call the auth ticket store filter method with the provided session query + private DefaultSessionManagementService CreateSut() => new(persistedGrantStore, backChannelLogoutService, serverSessionTicketStore, serverSessionStore, telemetry); [Fact] public async Task QuerySessionsAsync_WhenNullFilterProvided_ShouldUseDefaultValues() @@ -47,10 +45,10 @@ public async Task QuerySessionsAsync_WhenNullFilterProvided_ShouldUseDefaultValu QueryResult actual = await sut.QuerySessionsAsync(null, TestContext.Current.CancellationToken); - actual.Should().Be(fakeResult); + actual.Should().BeEquivalentTo(FakeResult, cnf => cnf.Excluding(x => x.Results)); Mock.Get(serverSessionTicketStore) - .Verify(x => x.FilterServerAuthenticationTickets(null)); + .Verify(x => x.FilterServerAuthenticationTickets(null, TestContext.Current.CancellationToken)); } [Fact] @@ -61,82 +59,355 @@ public async Task QuerySessionsAsync_WhenFilterProvided_ShouldUseDefaultValues() QueryResult actual = await sut.QuerySessionsAsync(fakeQuery, TestContext.Current.CancellationToken); - actual.Should().Be(fakeResult); + actual.Should().BeEquivalentTo(FakeResult, cnf => cnf.Excluding(x => x.Results)); Mock.Get(serverSessionTicketStore) - .Verify(x => x.FilterServerAuthenticationTickets(fakeQuery)); + .Verify(x => x.FilterServerAuthenticationTickets(fakeQuery, TestContext.Current.CancellationToken)); } - - /// TODO: implement removal tests, types of query to test - /// 1. Remove called with sessionId specified, should remove sessions with the specified sessionId - /// 2. Remove called with subjectId specified, should remove sessions with the specified subjectId - /// 3. Remove called with client IDs specified, should only trigger back channel notification and revocations for those clients - /// 4. Remove called with remove sessions set to false, shouldn't remove sessions - /// 5. Remove called with send backchannel set to false, shouldn't send backchannel - /// 6. Remove called with revoke tokens set to false, shouldn't revoke tokens - /// 7. Remove called with revoke consents set to false, shouldn't revoke consents [Fact] - public async Task RemoveSessionsAsync_WhenSessionIdSpecified_ShouldRemoveAllSessionsWithThatSessionId() + public async Task QuerySessionsAsync_WhenResultsReturned_ShouldMapToUserSessionCorrectly() { - RemoveSessionsContext fakeContext = new RemoveSessionsContext(); + QueryResult fakeResultWithData = new() + { + ResultsToken = "sess1,sess4", + HasPrevResults = false, + HasNextResults = false, + TotalCount = 4, + TotalPages = 1, + CurrentPage = 1, + Results = [ + GenerateAuthenticationTicketFilterResult("sess1","SchemeA", "bob", "session-0001", "Robert", clientIds: ["clientA"]), + GenerateAuthenticationTicketFilterResult("sess2","SchemeA", "alice", "session-0002", "Alice"), + GenerateAuthenticationTicketFilterResult("sess3","SchemeB", "bob", "session-0003", "Robert"), + GenerateAuthenticationTicketFilterResult("sess4","SchemeB", "sam", "session-0004", "Samantha", clientIds: ["clientA", "clientB"]), + ] + }; + + Mock.Get(serverSessionTicketStore) + .Setup(x => x.FilterServerAuthenticationTickets(It.IsAny(), TestContext.Current.CancellationToken)) + .ReturnsAsync(fakeResultWithData); + + SessionQuery fakeQuery = new SessionQuery(); DefaultSessionManagementService sut = CreateSut(); - await sut.RemoveSessionsAsync(fakeContext, TestContext.Current.CancellationToken); + QueryResult actual = await sut.QuerySessionsAsync(fakeQuery, TestContext.Current.CancellationToken); + + actual.Should().BeEquivalentTo(fakeResultWithData, cnf => cnf.Excluding(x => x.Results)); + + actual.Results.Should().NotBeNullOrEmpty(); + actual.Results.Should().HaveCount(fakeResultWithData.Results!.Count); + + foreach (var (expected, actualSession) in fakeResultWithData.Results.Zip(actual.Results, (e, a) => (e, a))) + { + actualSession.SubjectId.Should().Be(expected.Session.SubjectId); + actualSession.SessionId.Should().Be(expected.Session.SessionId); + actualSession.DisplayName.Should().Be(expected.Session.DisplayName); + actualSession.Created.Should().Be(expected.Session.Created); + actualSession.Renewed.Should().Be(expected.Session.Renewed); + actualSession.Expires.Should().Be(expected.Session.Expires); + actualSession.AuthenticationTicket.Should().BeEquivalentTo(expected.AuthTicket); + actualSession.ClientIds.Should().BeEquivalentTo(expected.AuthTicket!.Properties.GetClientList()); + } + + Mock.Get(serverSessionTicketStore) + .Verify(x => x.FilterServerAuthenticationTickets(fakeQuery, TestContext.Current.CancellationToken)); } - [Fact] - public async Task RemoveSessionsAsync_WhenSubjectIdSpecified_ShouldRemoveAllSessionsWithThatSubjectId() + private static readonly string[] AllGrantTypes = [..IdentityServerConstants.PersistedGrantTypes.PersistedGrantTokenTypes, IdentityServerConstants.PersistedGrantTypes.UserConsent]; + private static readonly string[] TokenGrantTypes = [..IdentityServerConstants.PersistedGrantTypes.PersistedGrantTokenTypes]; + private static readonly string[] ConsentGrantTypes = [IdentityServerConstants.PersistedGrantTypes.UserConsent]; + + [Theory] + [InlineData(null, "session-002")] + [InlineData("alice", null)] + [InlineData("alice", "session-002")] + public async Task RemoveSessionsAsync_WhenFilterSpecified_ShouldRemoveAllSessionsWithUsingFilter(string? testSubjectIdFilter, string? testSessionIdFilter) { - RemoveSessionsContext fakeContext = new RemoveSessionsContext(); + List fakeSessions = + [ + GenerateAuthenticationTicketFilterResult("key2", "SchemeA", "alice", "session-0002", "Alice"), + GenerateAuthenticationTicketFilterResult("key4", "SchemeB", "alice", "session-0002", "Alice"), + GenerateAuthenticationTicketFilterResult("key7", "SchemeC", "alice", "session-0002", "Alice"), + ]; + + Mock.Get(serverSessionTicketStore) + .Setup(x => x.FilterServerAuthenticationTickets(testSubjectIdFilter, testSessionIdFilter)) + .ReturnsAsync(fakeSessions); + + RemoveSessionsContext fakeContext = new RemoveSessionsContext + { + SubjectId = testSubjectIdFilter, SessionId = testSessionIdFilter, + }; DefaultSessionManagementService sut = CreateSut(); await sut.RemoveSessionsAsync(fakeContext, TestContext.Current.CancellationToken); + + foreach (var fakeSession in fakeSessions) + { + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(ctx => + ctx.SessionId == fakeSession.Session.SessionId && + ctx.SubjectId == fakeSession.Session.SubjectId && + fakeSession.AuthTicket != null && + ctx.ClientIds == fakeSession.AuthTicket.Properties.GetClientList()))); + } + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(f => + f.SessionId == fakeContext.SessionId && + f.SubjectId == fakeContext.SubjectId && + f.Types.AsEnumerable().ToHashSet().SetEquals(AllGrantTypes) && + f.ClientIds == (fakeContext.ClientIds ?? Array.Empty())))); + + Mock.Get(serverSessionStore) + .Verify(x => x.DeleteSessions(fakeContext.SubjectId, fakeContext.SessionId)); } [Fact] public async Task RemoveSessionsAsync_WhenClientIdsProvided_ShouldOnlyTriggerBackchannelNotificationsAndRevocationsForThoseClients() { - RemoveSessionsContext fakeContext = new RemoveSessionsContext(); + string[] fakeClientIds = ["client-a", "client-b", "client-c", "client-d"]; + var fakeSession = GenerateAuthenticationTicketFilterResult("key2", "SchemeA", "alice", "session-0002", "Alice", clientIds: fakeClientIds); + List fakeSessions = [fakeSession]; + + Mock.Get(serverSessionTicketStore) + .Setup(x => x.FilterServerAuthenticationTickets("alice", null)) + .ReturnsAsync(fakeSessions); + + RemoveSessionsContext fakeContext = new RemoveSessionsContext + { + SubjectId = "alice", SessionId = null, ClientIds = ["client-b", "client-d", "client-f"], + }; DefaultSessionManagementService sut = CreateSut(); await sut.RemoveSessionsAsync(fakeContext, TestContext.Current.CancellationToken); + + string[] expectedClientIds = ["client-b", "client-d"]; + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(ctx => + ctx.SessionId == fakeSession.Session.SessionId && + ctx.SubjectId == fakeSession.Session.SubjectId && + ctx.ClientIds.ToHashSet().SetEquals(expectedClientIds)))); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(f => + f.SessionId == fakeContext.SessionId && + f.SubjectId == fakeContext.SubjectId && + f.Types.AsEnumerable().ToHashSet().SetEquals(AllGrantTypes) && + f.ClientIds.ToHashSet().SetEquals(fakeContext.ClientIds ?? Array.Empty())))); + + Mock.Get(serverSessionStore) + .Verify(x => x.DeleteSessions(fakeContext.SubjectId, fakeContext.SessionId)); } [Fact] public async Task RemoveSessionsAsync_WhenRemoveSessionsSetToFalse_ShouldNotRemoveSessions() { - RemoveSessionsContext fakeContext = new RemoveSessionsContext(); + string fakeSessionId = "session-0002"; + List fakeSessions = + [ + GenerateAuthenticationTicketFilterResult("key2", "SchemeA", "alice", "session-0002", "Alice"), + GenerateAuthenticationTicketFilterResult("key4", "SchemeB", "alice", "session-0002", "Alice"), + GenerateAuthenticationTicketFilterResult("key7", "SchemeC", "alice", "session-0002", "Alice"), + ]; + + Mock.Get(serverSessionTicketStore) + .Setup(x => x.FilterServerAuthenticationTickets(null, fakeSessionId)) + .ReturnsAsync(fakeSessions); + + RemoveSessionsContext fakeContext = new RemoveSessionsContext + { + SubjectId = null, SessionId = fakeSessionId, RemoveServerSideSession = false, + }; DefaultSessionManagementService sut = CreateSut(); await sut.RemoveSessionsAsync(fakeContext, TestContext.Current.CancellationToken); + + foreach (var fakeSession in fakeSessions) + { + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(ctx => + ctx.SessionId == fakeSession.Session.SessionId && + ctx.SubjectId == fakeSession.Session.SubjectId && + fakeSession.AuthTicket != null && + ctx.ClientIds.ToHashSet().SetEquals(fakeSession.AuthTicket.Properties.GetClientList())))); + } + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(f => + f.SessionId == fakeContext.SessionId && + f.SubjectId == fakeContext.SubjectId && + f.Types.AsEnumerable().ToHashSet().SetEquals(AllGrantTypes) && + f.ClientIds.ToHashSet().SetEquals(fakeContext.ClientIds ?? Array.Empty())))); + + Mock.Get(serverSessionStore) + .Verify(x => x.DeleteSessions(fakeContext.SubjectId, fakeContext.SessionId), Times.Never); } [Fact] public async Task RemoveSessionsAsync_WhenSendBackchannelFalse_ShouldNotSendBackchannelNotification() { - RemoveSessionsContext fakeContext = new RemoveSessionsContext(); + string fakeSessionId = "session-0002"; + List fakeSessions = + [ + GenerateAuthenticationTicketFilterResult("key2", "SchemeA", "alice", "session-0002", "Alice"), + GenerateAuthenticationTicketFilterResult("key4", "SchemeB", "alice", "session-0002", "Alice"), + GenerateAuthenticationTicketFilterResult("key7", "SchemeC", "alice", "session-0002", "Alice"), + ]; + + Mock.Get(serverSessionTicketStore) + .Setup(x => x.FilterServerAuthenticationTickets(null, fakeSessionId)) + .ReturnsAsync(fakeSessions); + + RemoveSessionsContext fakeContext = new RemoveSessionsContext + { + SubjectId = null, SessionId = fakeSessionId, SendBackchannelLogoutNotification = false, + }; DefaultSessionManagementService sut = CreateSut(); await sut.RemoveSessionsAsync(fakeContext, TestContext.Current.CancellationToken); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.IsAny()), Times.Never); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(f => + f.SessionId == fakeContext.SessionId && + f.SubjectId == fakeContext.SubjectId && + f.Types.AsEnumerable().ToHashSet().SetEquals(AllGrantTypes) && + f.ClientIds.ToHashSet().SetEquals(fakeContext.ClientIds ?? Array.Empty())))); + + Mock.Get(serverSessionStore) + .Verify(x => x.DeleteSessions(fakeContext.SubjectId, fakeContext.SessionId)); } [Fact] public async Task RemoveSessionsAsync_WhenRevokeTokensFalse_ShouldNotRevokeTokens() { - RemoveSessionsContext fakeContext = new RemoveSessionsContext(); + string fakeSessionId = "session-0002"; + List fakeSessions = + [ + GenerateAuthenticationTicketFilterResult("key2", "SchemeA", "alice", "session-0002", "Alice"), + GenerateAuthenticationTicketFilterResult("key4", "SchemeB", "alice", "session-0002", "Alice"), + GenerateAuthenticationTicketFilterResult("key7", "SchemeC", "alice", "session-0002", "Alice"), + ]; + + Mock.Get(serverSessionTicketStore) + .Setup(x => x.FilterServerAuthenticationTickets(null, fakeSessionId)) + .ReturnsAsync(fakeSessions); + + RemoveSessionsContext fakeContext = new RemoveSessionsContext + { + SubjectId = null, SessionId = fakeSessionId, RevokeTokens = false, + }; DefaultSessionManagementService sut = CreateSut(); await sut.RemoveSessionsAsync(fakeContext, TestContext.Current.CancellationToken); + + foreach (var fakeSession in fakeSessions) + { + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(ctx => + ctx.SessionId == fakeSession.Session.SessionId && + ctx.SubjectId == fakeSession.Session.SubjectId && + fakeSession.AuthTicket != null && + ctx.ClientIds.ToHashSet().SetEquals(fakeSession.AuthTicket.Properties.GetClientList())))); + } + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(f => + f.SessionId == fakeContext.SessionId && + f.SubjectId == fakeContext.SubjectId && + f.Types.AsEnumerable().ToHashSet().SetEquals(ConsentGrantTypes) && + f.ClientIds.ToHashSet().SetEquals(fakeContext.ClientIds ?? Array.Empty())))); + + Mock.Get(serverSessionStore) + .Verify(x => x.DeleteSessions(fakeContext.SubjectId, fakeContext.SessionId)); } [Fact] public async Task RemoveSessionsAsync_WhenRevokeConsentsFalse_ShouldNotRevokeConsents() { - RemoveSessionsContext fakeContext = new RemoveSessionsContext(); + string fakeSessionId = "session-0002"; + List fakeSessions = + [ + GenerateAuthenticationTicketFilterResult("key2", "SchemeA", "alice", "session-0002", "Alice"), + GenerateAuthenticationTicketFilterResult("key4", "SchemeB", "alice", "session-0002", "Alice"), + GenerateAuthenticationTicketFilterResult("key7", "SchemeC", "alice", "session-0002", "Alice"), + ]; + + Mock.Get(serverSessionTicketStore) + .Setup(x => x.FilterServerAuthenticationTickets(null, fakeSessionId)) + .ReturnsAsync(fakeSessions); + + RemoveSessionsContext fakeContext = new RemoveSessionsContext + { + SubjectId = null, SessionId = fakeSessionId, RevokeConsents = false, + }; + DefaultSessionManagementService sut = CreateSut(); + + await sut.RemoveSessionsAsync(fakeContext, TestContext.Current.CancellationToken); + + foreach (var fakeSession in fakeSessions) + { + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(ctx => + ctx.SessionId == fakeSession.Session.SessionId && + ctx.SubjectId == fakeSession.Session.SubjectId && + fakeSession.AuthTicket != null && + ctx.ClientIds.ToHashSet().SetEquals(fakeSession.AuthTicket.Properties.GetClientList())))); + } + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(f => + f.SessionId == fakeContext.SessionId && + f.SubjectId == fakeContext.SubjectId && + f.Types.AsEnumerable().ToHashSet().SetEquals(TokenGrantTypes) && + f.ClientIds.ToHashSet().SetEquals(fakeContext.ClientIds ?? Array.Empty())))); + + Mock.Get(serverSessionStore) + .Verify(x => x.DeleteSessions(fakeContext.SubjectId, fakeContext.SessionId)); + } + + [Fact] + public async Task RemoveSessionsAsync_WhenRevokeTokensAndConsentsFalse_ShouldNotRevokeAnyGrants() + { + string fakeSessionId = "session-0002"; + List fakeSessions = + [ + GenerateAuthenticationTicketFilterResult("key2", "SchemeA", "alice", "session-0002", "Alice"), + GenerateAuthenticationTicketFilterResult("key4", "SchemeB", "alice", "session-0002", "Alice"), + GenerateAuthenticationTicketFilterResult("key7", "SchemeC", "alice", "session-0002", "Alice"), + ]; + + Mock.Get(serverSessionTicketStore) + .Setup(x => x.FilterServerAuthenticationTickets(null, fakeSessionId)) + .ReturnsAsync(fakeSessions); + + RemoveSessionsContext fakeContext = new RemoveSessionsContext + { + SubjectId = null, SessionId = fakeSessionId, RevokeTokens = false, RevokeConsents = false, + }; DefaultSessionManagementService sut = CreateSut(); await sut.RemoveSessionsAsync(fakeContext, TestContext.Current.CancellationToken); + + foreach (var fakeSession in fakeSessions) + { + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(ctx => + ctx.SessionId == fakeSession.Session.SessionId && + ctx.SubjectId == fakeSession.Session.SubjectId && + fakeSession.AuthTicket != null && + ctx.ClientIds.ToHashSet().SetEquals(fakeSession.AuthTicket.Properties.GetClientList())))); + } + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.IsAny()), Times.Never); + + Mock.Get(serverSessionStore) + .Verify(x => x.DeleteSessions(fakeContext.SubjectId, fakeContext.SessionId)); } [Fact] @@ -164,7 +435,7 @@ public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace() Mock.Get(telemetry) .Verify(t => t.Trace( - TelemetryConstants.TraceCategories.Stores, sut, method.traceMethodName)); + TelemetryConstants.TraceCategories.Services, sut, method.traceMethodName)); Mock.Get(trace).Verify(t => t.Dispose(), Times.Once); } @@ -175,4 +446,26 @@ public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace() .Select(m => m.Name) .Should().BeEquivalentTo(methods.Select(m => m.traceMethodName)); } + + private static AuthenticationTicketFilterResult GenerateAuthenticationTicketFilterResult( + string key, + string authScheme, + string subjectId, + string sessionId, + string displayName, + DateTime? created = null, + DateTime? renewed = null, + DateTime? expires = null, + string[]? clientIds = null) + { + created ??= new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + renewed ??= new DateTime(2026, 1, 2, 12, 0, 0, DateTimeKind.Utc); + expires ??= new DateTime(2026, 1, 31, 12, 0, 0, DateTimeKind.Utc); + + return new AuthenticationTicketFilterResult + { + Session = ServerSessionTestGenerators.FakeSession(key, authScheme, sessionId, subjectId, displayName, string.Empty, created, renewed, expires), + AuthTicket = ServerSessionTestGenerators.GenerateAuthenticationTicket(authScheme, subjectId, sessionId, displayName, renewed, expires, clientIds), + }; + } } \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/ServerSessionTicketStoreTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/ServerSessionTicketStoreTests.cs index 8bfea201d..35f7f26d6 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/ServerSessionTicketStoreTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/ServerSessionTicketStoreTests.cs @@ -21,6 +21,7 @@ using Open.IdentityServer.Services; using Open.IdentityServer.Stores; using Open.IdentityServer.Stores.Serialization; +using Open.IdentityServer.UnitTests.Utilities.Generators; using Xunit; namespace Open.IdentityServer.UnitTests.Stores.Default; @@ -61,7 +62,7 @@ public async Task StoreAsync_WhenOptionalValuesNotProvided_ShouldUseCorrectDefau string subjectId = Guid.NewGuid().ToString(); string sessionId = Guid.NewGuid().ToString(); - AuthenticationTicket authenticationTicket = GenerateAuthenticationTicket(authScheme, subjectId, sessionId); + AuthenticationTicket authenticationTicket = ServerSessionTestGenerators.GenerateAuthenticationTicket(authScheme, subjectId, sessionId); IdentityServerServerSideSessions? createdSessionModel = null; Mock.Get(serverServerSideSessionStore) @@ -105,7 +106,7 @@ public async Task StoreAsync_WhenOptionalValuesProvided_ShouldUseThem() DateTime expiresUtc = new(2026, 02, 19, 12, 0, 0, DateTimeKind.Utc); AuthenticationTicket authenticationTicket = - GenerateAuthenticationTicket(authScheme, subjectId, sessionId, displayName, issuedUtc, expiresUtc); + ServerSessionTestGenerators.GenerateAuthenticationTicket(authScheme, subjectId, sessionId, displayName, issuedUtc, expiresUtc); IdentityServerServerSideSessions? createdSessionModel = null; Mock.Get(serverServerSideSessionStore) @@ -155,7 +156,7 @@ public async Task RenewAsync_WhenOptionalValuesNotProvided_ShouldUseCorrectDefau string subjectId = Guid.NewGuid().ToString(); string sessionId = Guid.NewGuid().ToString(); - AuthenticationTicket authenticationTicket = GenerateAuthenticationTicket(authScheme, subjectId, sessionId); + AuthenticationTicket authenticationTicket = ServerSessionTestGenerators.GenerateAuthenticationTicket(authScheme, subjectId, sessionId); Mock.Get(serverServerSideSessionStore) .Setup(x => x.GetSession(existingSession.Key)) @@ -213,7 +214,7 @@ public async Task RenewAsync_WhenOptionalValuesProvided_ShouldUseThem() DateTime expiresUtc = new(2026, 02, 19, 12, 0, 0, DateTimeKind.Utc); AuthenticationTicket authenticationTicket = - GenerateAuthenticationTicket(authScheme, subjectId, sessionId, displayName, issuedUtc, expiresUtc); + ServerSessionTestGenerators.GenerateAuthenticationTicket(authScheme, subjectId, sessionId, displayName, issuedUtc, expiresUtc); Mock.Get(serverServerSideSessionStore) .Setup(x => x.GetSession(existingSession.Key)) @@ -257,7 +258,7 @@ public async Task RenewAsync_WhenNoExistingSessionWithKey_ShouldCreateNewSession string subjectId = Guid.NewGuid().ToString(); string sessionId = Guid.NewGuid().ToString(); - AuthenticationTicket authenticationTicket = GenerateAuthenticationTicket(authScheme, subjectId, sessionId); + AuthenticationTicket authenticationTicket = ServerSessionTestGenerators.GenerateAuthenticationTicket(authScheme, subjectId, sessionId); IdentityServerServerSideSessions? createdSessionModel = null; Mock.Get(serverServerSideSessionStore) @@ -350,7 +351,7 @@ public async Task RetrieveAsync_WhenSessionStoredForKey_ShouldReturnDeserialized Renewed = new DateTime(2026, 1, 2, 12, 0, 0, DateTimeKind.Utc), Expires = new DateTime(2026, 1, 31, 12, 0, 0, DateTimeKind.Utc), }; - SerializedAuthenticationTicket authenticationTicket = GenerateSerializedAuthenticationTicket( + SerializedAuthenticationTicket authenticationTicket = ServerSessionTestGenerators.GenerateSerializedAuthenticationTicket( existingSession.Scheme, existingSession.SubjectId, existingSession.SessionId, existingSession.DisplayName, existingSession.Renewed, existingSession.Expires); existingSession.Data = GenerateFakeData(authenticationTicket); @@ -408,9 +409,9 @@ public async Task FilterServerAuthenticationTickets_WhenSessionDataDeserialisati const string testSessionId = "session-0"; IEnumerable sessions = [ - FakeSession(key: "key-0", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), - FakeSession(key: "key-4", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith", data: data), - FakeSession(key: "key-5", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), + ServerSessionTestGenerators.FakeSession(key: "key-0", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), + ServerSessionTestGenerators.FakeSession(key: "key-4", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith", data: data), + ServerSessionTestGenerators.FakeSession(key: "key-5", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), ]; List expectedAuthTickets = []; @@ -451,9 +452,9 @@ public async Task FilterServerAuthenticationTickets_WhenSessionReturnedFromStore const string testSessionId = "session-0"; IEnumerable sessions = [ - FakeSession(key: "key-0", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), - FakeSession(key: "key-4", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), - FakeSession(key: "key-5", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), + ServerSessionTestGenerators.FakeSession(key: "key-0", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), + ServerSessionTestGenerators.FakeSession(key: "key-4", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), + ServerSessionTestGenerators.FakeSession(key: "key-5", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), ]; List expectedAuthTickets = []; @@ -483,9 +484,9 @@ public async Task GetAndRemoveExpiredSessions_WhenSessionReturnedFromStore_Shoul const int batchSize = 5; IEnumerable sessions = [ - FakeSession(key: "key-0", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), - FakeSession(key: "key-4", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), - FakeSession(key: "key-5", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), + ServerSessionTestGenerators.FakeSession(key: "key-0", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), + ServerSessionTestGenerators.FakeSession(key: "key-4", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), + ServerSessionTestGenerators.FakeSession(key: "key-5", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), ]; List expectedAuthTickets = []; @@ -529,9 +530,9 @@ public async Task FilterServerAuthenticationTickets_WhenQueryProvided_ShouldCall QueryResult fakeResult; IEnumerable sessions = [ - FakeSession(key: "key-0", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), - FakeSession(key: "key-4", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), - FakeSession(key: "key-5", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), + ServerSessionTestGenerators.FakeSession(key: "key-0", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), + ServerSessionTestGenerators.FakeSession(key: "key-4", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), + ServerSessionTestGenerators.FakeSession(key: "key-5", scheme: "AuthScheme", subjectId: "bob", sessionId: "session-0", displayName: "Bob Smith"), ]; List expectedAuthTickets = []; @@ -559,7 +560,7 @@ public async Task FilterServerAuthenticationTickets_WhenQueryProvided_ShouldCall public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace() { AuthenticationTicket authTicket = - GenerateAuthenticationTicket("FakeScheme", Guid.NewGuid().ToString(), Guid.NewGuid().ToString()); + ServerSessionTestGenerators.GenerateAuthenticationTicket("FakeScheme", Guid.NewGuid().ToString(), Guid.NewGuid().ToString()); List<(Func actMethod, string traceMethodName)> methods = [ @@ -604,7 +605,7 @@ private IdentityServerServerSideSessions GenerateSerialisedData( { if (string.IsNullOrWhiteSpace(identityServerServerSideSessions.Data)) { - SerializedAuthenticationTicket authenticationTicket = GenerateSerializedAuthenticationTicket( + SerializedAuthenticationTicket authenticationTicket = ServerSessionTestGenerators.GenerateSerializedAuthenticationTicket( identityServerServerSideSessions.Scheme, identityServerServerSideSessions.SubjectId, identityServerServerSideSessions.SessionId, identityServerServerSideSessions.DisplayName, identityServerServerSideSessions.Renewed, identityServerServerSideSessions.Expires); identityServerServerSideSessions.Data = GenerateFakeData(authenticationTicket); @@ -613,21 +614,6 @@ private IdentityServerServerSideSessions GenerateSerialisedData( } return identityServerServerSideSessions; - } - - private AuthenticationTicket GenerateAuthenticationTicket(string authScheme, string? subjectId, string? sessionId, - string? displayName = null, DateTimeOffset? issuedUtc = null, DateTimeOffset? expiresUtc = null) - { - IdentityServerUser user = new(subjectId); - AuthenticationProperties properties = new(); - - properties.SetSessionId(sessionId); - - user.DisplayName = displayName; - properties.IssuedUtc = issuedUtc; - properties.ExpiresUtc = expiresUtc; - - return new AuthenticationTicket(user.CreatePrincipal(), properties, authScheme); } private string GenerateFakeData(SerializedAuthenticationTicket serializedAuthenticationTicket) @@ -640,69 +626,4 @@ private string GenerateFakeData(SerializedAuthenticationTicket serializedAuthent return JsonSerializer.Serialize(sessionData, ServerSessionTicketStore.JsonSettings); } - - private SerializedAuthenticationTicket GenerateSerializedAuthenticationTicket(string authScheme, string? subjectId, - string? sessionId, string? displayName = null, DateTimeOffset? issuedUtc = null, - DateTimeOffset? expiresUtc = null) - { - List claims = []; - - if (subjectId != null) - { - claims.Add(new ClaimLite { Type = "sub", Value = subjectId, ValueType = "", Issuer = "", }); - } - - if (displayName != null) - { - claims.Add(new ClaimLite { Type = "name", Value = displayName, ValueType = "", Issuer = "", }); - } - - var items = new Dictionary(); - - if (sessionId != null) - { - items["session_id"] = sessionId; - } - - if (issuedUtc != null) - { - items[".issued"] = issuedUtc.Value.ToString("R"); - } - - if (expiresUtc != null) - { - items[".expires"] = expiresUtc.Value.ToString("R"); - } - - return new SerializedAuthenticationTicket - { - Scheme = authScheme, - User = new ClaimsPrincipalLite - { - AuthenticationType = "Open.IdentityServer", - Claims = claims.ToArray(), - }, - Items = items, - }; - } - - private IdentityServerServerSideSessions FakeSession( - string key, - string scheme, - string sessionId, - string subjectId, - string displayName, - string? data = null, - DateTime? created = null, - DateTime? renewed = null, - DateTime? expires = null) - { - return new IdentityServerServerSideSessions - { - Key = key, Scheme = scheme, SessionId = sessionId, SubjectId = subjectId, DisplayName = displayName, Data = data ?? string.Empty, - Created = created ?? new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc), - Renewed = renewed ?? new DateTime(2026, 1, 2, 12, 0, 0, DateTimeKind.Utc), - Expires = expires ?? new DateTime(2026, 1, 31, 12, 0, 0, DateTimeKind.Utc), - }; - } } \ No newline at end of file diff --git a/src/Open.IdentityServer.Test.Utilities/Generators/ServerSessionTestGenerators.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Utilities/Generators/ServerSessionTestGenerators.cs similarity index 74% rename from src/Open.IdentityServer.Test.Utilities/Generators/ServerSessionTestGenerators.cs rename to src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Utilities/Generators/ServerSessionTestGenerators.cs index 3d54813a0..ad324d67c 100644 --- a/src/Open.IdentityServer.Test.Utilities/Generators/ServerSessionTestGenerators.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Utilities/Generators/ServerSessionTestGenerators.cs @@ -1,14 +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. + +using System; +using System.Collections.Generic; using Microsoft.AspNetCore.Authentication; using Open.IdentityServer.Extensions; using Open.IdentityServer.Models; using Open.IdentityServer.Stores.Serialization; -namespace Open.IdentityServer.Test.Utilities.Generators; +namespace Open.IdentityServer.UnitTests.Utilities.Generators; -public static class AuthenticationTicketGenerators +public static class ServerSessionTestGenerators { - public static AuthenticationTicket GenerateAuthenticationTicket(string authScheme, string? subjectId, string? sessionId, - string? displayName = null, DateTimeOffset? issuedUtc = null, DateTimeOffset? expiresUtc = null) + public static AuthenticationTicket GenerateAuthenticationTicket( + string authScheme, + string? subjectId, + string? sessionId, + string? displayName = null, + DateTimeOffset? issuedUtc = null, + DateTimeOffset? expiresUtc = null, + string[]? clientIds = null) { IdentityServerUser user = new(subjectId); AuthenticationProperties properties = new(); @@ -19,11 +30,20 @@ public static AuthenticationTicket GenerateAuthenticationTicket(string authSchem properties.IssuedUtc = issuedUtc; properties.ExpiresUtc = expiresUtc; + foreach (var clientId in clientIds ?? []) + { + properties.AddClientId(clientId); + } + return new AuthenticationTicket(user.CreatePrincipal(), properties, authScheme); } - public static SerializedAuthenticationTicket GenerateSerializedAuthenticationTicket(string authScheme, string? subjectId, - string? sessionId, string? displayName = null, DateTimeOffset? issuedUtc = null, + public static SerializedAuthenticationTicket GenerateSerializedAuthenticationTicket( + string authScheme, + string? subjectId, + string? sessionId, + string? displayName = null, + DateTimeOffset? issuedUtc = null, DateTimeOffset? expiresUtc = null) { List claims = []; diff --git a/src/Storage/src/Models/QueryResult.cs b/src/Storage/src/Models/QueryResult.cs index f15e71ee4..823328771 100644 --- a/src/Storage/src/Models/QueryResult.cs +++ b/src/Storage/src/Models/QueryResult.cs @@ -3,7 +3,9 @@ #nullable enable +using System; using System.Collections.Generic; +using System.Linq; namespace Open.IdentityServer.Models; @@ -62,4 +64,24 @@ public class QueryResult CurrentPage = 0, Results = [], }; + + /// + /// Maps a QueryResult results set from one type to another + /// + /// mapping function to use + /// type to map results to + /// + public QueryResult MapTo(Func mapper) + { + return new QueryResult + { + ResultsToken = ResultsToken, + HasPrevResults = HasPrevResults, + HasNextResults = HasNextResults, + TotalCount = TotalCount, + TotalPages = TotalPages, + CurrentPage = CurrentPage, + Results = Results.Select(mapper).ToList() + }; + } } \ No newline at end of file From 44dee409a4d46be5c2bc966fcba2f4ee6af17419 Mon Sep 17 00:00:00 2001 From: James Britton Date: Thu, 24 Sep 2026 13:48:28 +0100 Subject: [PATCH 6/8] feat: wired up sessions management in DI container --- .../BuilderExtensions/Additional.cs | 3 +++ .../BuilderExtensions/AdditionalTests.cs | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs index 2d0c43058..673c8732a 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs @@ -486,6 +486,9 @@ public static IIdentityServerBuilder AddServerSideSessions(this IIdentityServerB //Clean-up Service builder.Services.AddTransient(); builder.Services.AddSingleton(); + + //Add Management Service + builder.Services.TryAddScoped(); return builder; } diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/DependencyInjection/BuilderExtensions/AdditionalTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/DependencyInjection/BuilderExtensions/AdditionalTests.cs index 1ee8de94d..5d6ca0b62 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/DependencyInjection/BuilderExtensions/AdditionalTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/DependencyInjection/BuilderExtensions/AdditionalTests.cs @@ -76,6 +76,11 @@ public void AddServerSideSessions_WhenNoStoreConfigured_ShouldConfigureServerSid d.ServiceType == typeof(IHostedService) && d.ImplementationType == typeof(SessionCleanupHostedService) && d.Lifetime == ServiceLifetime.Singleton); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(ISessionManagementService) && + d.ImplementationType == typeof(DefaultSessionManagementService) && + d.Lifetime == ServiceLifetime.Scoped); } [Fact] @@ -131,6 +136,11 @@ public void AddServerSideSessions_WhenStoreConfigured_ShouldConfigureServerSideS d.ServiceType == typeof(IHostedService) && d.ImplementationType == typeof(SessionCleanupHostedService) && d.Lifetime == ServiceLifetime.Singleton); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(ISessionManagementService) && + d.ImplementationType == typeof(DefaultSessionManagementService) && + d.Lifetime == ServiceLifetime.Scoped); } } From 930ea3f2cbfe42dced963cc2c5d30a35f184feb2 Mon Sep 17 00:00:00 2001 From: James Britton Date: Thu, 24 Sep 2026 15:24:43 +0100 Subject: [PATCH 7/8] feat: added display name claim options to server-side session options --- .../Options/ServerSideSessionsOptions.cs | 17 +++++- .../Default/ServerSessionTicketStore.cs | 11 +++- .../Default/ServerSessionTicketStoreTests.cs | 55 ++++++++++++++++++- 3 files changed, 76 insertions(+), 7 deletions(-) diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs index 22aab09a5..bcbf595af 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs @@ -1,6 +1,8 @@ // 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; namespace Open.IdentityServer.Configuration; @@ -24,7 +26,7 @@ public class ServerSideSessionsOptions /// The default value is true /// public bool RemoveExpiredSessions { get; set; } = true; - + /// /// Specifies the frequency with which expired sessions are looked for and removed /// @@ -32,7 +34,7 @@ public class ServerSideSessionsOptions /// The default value is a TimeSpan of 10 minutes /// public TimeSpan RemoveExpiredSessionsFrequency { get; set; } = TimeSpan.FromMinutes(10); - + /// /// Specifies if the start time of the hosted service should be randomised to avoid limiting the occurrences of jobs /// running simultaneously in scenarios with multiple instances of Open.IdentityServer are running. @@ -41,7 +43,7 @@ public class ServerSideSessionsOptions /// The default value is true /// public bool FuzzExpiredSessionsFrequency { get; set; } = true; - + /// /// Specifies how many expired sessions should be removed in a single pass /// @@ -49,4 +51,13 @@ public class ServerSideSessionsOptions /// The default value is 100 /// public int RemoveExpiredSessionsBatchSize { get; set; } = 100; + + /// + /// The claim used to set a session's display name value + /// + /// + /// The default value is null + /// + public string? UserDisplayNameClaimType { get; set; } = null; + } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs index b3d8dccae..666ca4b44 100644 --- a/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs +++ b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs @@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Logging; +using Open.IdentityServer.Configuration; using Open.IdentityServer.DataProtection; using Open.IdentityServer.Extensions; using Open.IdentityServer.Models; @@ -27,14 +28,16 @@ namespace Open.IdentityServer.Stores; /// implementation in Open.IdentityServer /// /// -/// data prtection provider +/// data protection provider /// time provider +/// identit server options /// telemetry service /// the logger public class ServerSessionTicketStore( IIdentityServerServerSideSessionStore serverServerSideSessionStore, IDataProtectionProvider dataProtectionProvider, TimeProvider timeProvider, + IdentityServerOptions options, ITelemetryService telemetry, ILogger logger): IServerSessionTicketStore { @@ -174,13 +177,17 @@ public async Task> GetAndRemoveExp private async Task StoreNewSession(string key, AuthenticationTicket ticket) { + string? displayName = string.IsNullOrWhiteSpace(options.ServerSideSessions.UserDisplayNameClaimType) + ? null + : ticket.Principal.FindFirstValue(options.ServerSideSessions.UserDisplayNameClaimType); + IdentityServerServerSideSessions serverSideSession = new IdentityServerServerSideSessions { Key = key, Scheme = ticket.AuthenticationScheme, SubjectId = ticket.Principal.GetSubjectId(), SessionId = ticket.Properties.GetSessionId(), - DisplayName = ticket.Principal.FindFirstValue(JwtClaimTypes.Name), //Make configurable? + DisplayName = displayName, Created = ticket.Properties.IssuedUtc?.UtcDateTime ?? timeProvider.GetUtcNow().UtcDateTime, Renewed = ticket.Properties.IssuedUtc?.UtcDateTime ?? timeProvider.GetUtcNow().UtcDateTime, Expires = ticket.Properties.ExpiresUtc?.UtcDateTime, diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/ServerSessionTicketStoreTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/ServerSessionTicketStoreTests.cs index 35f7f26d6..65fee6f07 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/ServerSessionTicketStoreTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/ServerSessionTicketStoreTests.cs @@ -14,6 +14,7 @@ using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Time.Testing; using Moq; +using Open.IdentityServer.Configuration; using Open.IdentityServer.DataProtection; using Open.IdentityServer.EntityFramework.IntegrationTests; using Open.IdentityServer.Extensions; @@ -37,6 +38,8 @@ public class ServerSessionTicketStoreTests private readonly ITelemetryService telemetry = Mock.Of(); private readonly MockLogger logger = new(); + private readonly IdentityServerOptions fakeOptions = new(); + private static readonly DateTime FakeNow = new(2026, 01, 01, 12, 0, 0, DateTimeKind.Utc); public ServerSessionTicketStoreTests() @@ -53,7 +56,7 @@ public ServerSessionTicketStoreTests() } private ServerSessionTicketStore CreateSut() => new(serverServerSideSessionStore, dataProtectionProvider, - fakeTimeProvider, telemetry, logger); + fakeTimeProvider, fakeOptions, telemetry, logger); [Fact] public async Task StoreAsync_WhenOptionalValuesNotProvided_ShouldUseCorrectDefaults() @@ -123,7 +126,55 @@ public async Task StoreAsync_WhenOptionalValuesProvided_ShouldUseThem() createdSessionModel.Scheme.Should().Be(authScheme); createdSessionModel.SessionId.Should().Be(sessionId); createdSessionModel.SubjectId.Should().Be(subjectId); - createdSessionModel.DisplayName.Should().Be(displayName); + createdSessionModel.DisplayName.Should().BeNull(); + createdSessionModel.Created.Should().Be(issuedUtc); + createdSessionModel.Renewed.Should().Be(issuedUtc); + createdSessionModel.Expires.Should().Be(expiresUtc); + + var jsonElement = JsonElement.Parse(createdSessionModel.Data); + + jsonElement.GetProperty("Version").GetInt32().Should().Be(1); + var actualPayload = jsonElement.GetProperty("Payload").GetString(); + actualPayload.Should().NotBeNull(); + + string expectedJson = JsonSerializer.Serialize(authenticationTicket.ToSerializableObj(), + ServerSessionTicketStore.JsonSettings); + dataProtector.ValidateProtectedData(actualPayload, expectedJson); + } + + [Theory] + [InlineData(JwtClaimTypes.Name, "Fake User")] + [InlineData(JwtClaimTypes.Email, null)] + public async Task StoreAsync_WhenDisplayNameClaimSet_ShouldUseClaimValueIfSet(string testType, string? expectedDisplayNameValue) + { + const string authScheme = "FakeAuthScheme"; + string subjectId = Guid.NewGuid().ToString(); + string sessionId = Guid.NewGuid().ToString(); + const string displayName = "Fake User"; + DateTime issuedUtc = new(2026, 02, 19, 12, 0, 0, DateTimeKind.Utc); + DateTime expiresUtc = new(2026, 02, 19, 12, 0, 0, DateTimeKind.Utc); + + fakeOptions.ServerSideSessions.UserDisplayNameClaimType = testType; + + AuthenticationTicket authenticationTicket = + ServerSessionTestGenerators.GenerateAuthenticationTicket(authScheme, subjectId, sessionId, displayName, issuedUtc, expiresUtc); + + IdentityServerServerSideSessions? createdSessionModel = null; + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.CreateSession(It.IsAny())) + .Callback((session) => { createdSessionModel = session; }); + + ServerSessionTicketStore sut = CreateSut(); + + string actualKey = await sut.StoreAsync(authenticationTicket); + + createdSessionModel.Should().NotBeNull(); + createdSessionModel.Key.Should().NotBeNullOrWhiteSpace(); + createdSessionModel.Key.Should().Be(actualKey); + createdSessionModel.Scheme.Should().Be(authScheme); + createdSessionModel.SessionId.Should().Be(sessionId); + createdSessionModel.SubjectId.Should().Be(subjectId); + createdSessionModel.DisplayName.Should().Be(expectedDisplayNameValue); createdSessionModel.Created.Should().Be(issuedUtc); createdSessionModel.Renewed.Should().Be(issuedUtc); createdSessionModel.Expires.Should().Be(expiresUtc); From 2764629757b4a1a91e28234c5e3aa4e65cfaa571 Mon Sep 17 00:00:00 2001 From: James Britton Date: Fri, 25 Sep 2026 14:34:27 +0100 Subject: [PATCH 8/8] pr: correcting issue identitifed in pull request --- .../IdentityServerServerSideSessionStore.cs | 1 - .../IdentityServerServerSideSessionStoreTests.cs | 8 -------- .../Open.IdentityServer.Test.Utilities.csproj | 4 ---- .../src/Models/Contexts/RemoveSessionsContext.cs | 4 ++++ .../Services/Default/DefaultSessionManagementService.cs | 8 ++++---- 5 files changed, 8 insertions(+), 17 deletions(-) rename src/EntityFramework.Storage/src/Stores/{Compatibility => }/IdentityServerServerSideSessionStore.cs (99%) diff --git a/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs b/src/EntityFramework.Storage/src/Stores/IdentityServerServerSideSessionStore.cs similarity index 99% rename from src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs rename to src/EntityFramework.Storage/src/Stores/IdentityServerServerSideSessionStore.cs index 7449b3dc2..560c1af71 100644 --- a/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs +++ b/src/EntityFramework.Storage/src/Stores/IdentityServerServerSideSessionStore.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Linq; using System.Threading; using System.Threading.Tasks; diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs index 698ff6f69..d7b41db70 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs @@ -631,14 +631,6 @@ public async Task GetAndRemoveExpiredSessions_WhenUnspecifiedTimezoneInDbEntitie actual.Should().HaveCount(1); actual.Should().Contain(x => x.Key == expiredSession0.Key); } - - /// TODO: implement filter with query tests, types of query to test - /// 1. When no filter is provided, should use default values - /// 2. When no token is provided, it should get the first page of results - /// 3. When a token is provided, it should get the next page relative to the provided token - /// 4. When a subjectId filter is provided, it should filter the results using it - /// 5. When a sessionId filter is provided, it should filter results using it - /// 6. When a display name filter is provided, it should filter results using it [Theory, MemberData(nameof(TestDatabaseProviders))] public async Task FilterSessions_WithQuery_WhenNoResults_ShouldEmptyResultsSet(DbContextOptions options) diff --git a/src/Open.IdentityServer.Test.Utilities/Open.IdentityServer.Test.Utilities.csproj b/src/Open.IdentityServer.Test.Utilities/Open.IdentityServer.Test.Utilities.csproj index 23677b054..237d66167 100644 --- a/src/Open.IdentityServer.Test.Utilities/Open.IdentityServer.Test.Utilities.csproj +++ b/src/Open.IdentityServer.Test.Utilities/Open.IdentityServer.Test.Utilities.csproj @@ -5,9 +5,5 @@ enable enable - - - - diff --git a/src/Open.IdentityServer/src/Models/Contexts/RemoveSessionsContext.cs b/src/Open.IdentityServer/src/Models/Contexts/RemoveSessionsContext.cs index cb04830b5..2ab72957b 100644 --- a/src/Open.IdentityServer/src/Models/Contexts/RemoveSessionsContext.cs +++ b/src/Open.IdentityServer/src/Models/Contexts/RemoveSessionsContext.cs @@ -31,20 +31,24 @@ public class RemoveSessionsContext /// /// Specifies if the server-side session should be removed /// + /// default value is true public bool RemoveServerSideSession { get; set; } = true; /// /// Specifies if back-channel logout notifications should be sent /// + /// default value is true public bool SendBackchannelLogoutNotification { get; set; } = true; /// /// Specifies if tokens should be revoked for a client /// + /// default value is true public bool RevokeTokens { get; set; } = true; /// /// Specifies if consents should be revoked for a client /// + /// default value is true public bool RevokeConsents { get; set; } = true; } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs b/src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs index 25531a3c1..37df5259c 100644 --- a/src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs +++ b/src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs @@ -46,9 +46,9 @@ public async Task RemoveSessionsAsync(RemoveSessionsContext context, Cancellatio if (context.SendBackchannelLogoutNotification) { var sessions = await serverSessionTicketStore.FilterServerAuthenticationTickets(context.SubjectId, context.SessionId); - foreach (var sess in sessions) + foreach (var session in sessions) { - List? sessionClientList = sess.AuthTicket?.Properties.GetClientList().ToList(); + List? sessionClientList = session.AuthTicket?.Properties.GetClientList().ToList(); string[] clientIds = []; if (!sessionClientList.IsNullOrEmpty() && !context.ClientIds.IsNullOrEmpty()) @@ -58,8 +58,8 @@ public async Task RemoveSessionsAsync(RemoveSessionsContext context, Cancellatio await backChannelLogoutService.SendLogoutNotificationsAsync(new LogoutNotificationContext { - SubjectId = sess.Session.SubjectId, - SessionId = sess.Session.SessionId, + SubjectId = session.Session.SubjectId, + SessionId = session.Session.SessionId, ClientIds = clientIds, }); }