From 35e541427f0d32f9acf110a888e2ca625705f51a Mon Sep 17 00:00:00 2001 From: James Britton Date: Mon, 20 Jul 2026 11:09:42 +0100 Subject: [PATCH 01/59] feat: implement server side session store to be used when storing auth tickets --- src/Directory.Build.targets | 5 +- ...ntityServerServerSideSessionsExtensions.cs | 83 +++++ .../IdentityServerServerSideSessionStore.cs | 115 +++++++ .../test/IntegrationTests/MockLogger.cs | 47 +++ ...entityServerServerSideSessionStoreTests.cs | 322 ++++++++++++++++++ .../IdentityServerServerSideSessions.cs | 54 +++ .../IIdentityServerServerSideSessionStore.cs | 43 +++ 7 files changed, 667 insertions(+), 2 deletions(-) create mode 100644 src/EntityFramework.Storage/src/Mappers/IdentityServerServerSideSessionsExtensions.cs create mode 100644 src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs create mode 100644 src/EntityFramework.Storage/test/IntegrationTests/MockLogger.cs create mode 100644 src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs create mode 100644 src/Storage/src/Models/Compatibility/IdentityServerServerSideSessions.cs create mode 100644 src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 43ae18b05..2e58164dd 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -5,7 +5,7 @@ 10.0.5 10.0.10 - 2.0.0-* + 2.1.0-* @@ -61,7 +61,8 @@ - + + diff --git a/src/EntityFramework.Storage/src/Mappers/IdentityServerServerSideSessionsExtensions.cs b/src/EntityFramework.Storage/src/Mappers/IdentityServerServerSideSessionsExtensions.cs new file mode 100644 index 000000000..31e2ea4ae --- /dev/null +++ b/src/EntityFramework.Storage/src/Mappers/IdentityServerServerSideSessionsExtensions.cs @@ -0,0 +1,83 @@ +// 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.EntityFramework.Mappers; + +/// +/// Mapping extension methods for IdentityServerServerSideSessions objects +/// +public static class IdentityServerServerSideSessionsExtensions +{ + /// + /// Mapping extension methods for + /// + /// The entity. + extension(Entities.IdentityServerServerSideSessions sessionEntity) + { + /// + /// Mapper for to convert into an instance of + /// + /// mapped instance of + public Models.IdentityServerServerSideSessions ToModel() + { + return new Models.IdentityServerServerSideSessions + { + Key = sessionEntity.Key, + Scheme = sessionEntity.Scheme, + SubjectId = sessionEntity.SubjectId, + SessionId = sessionEntity.SessionId, + DisplayName = sessionEntity.DisplayName, + Created = sessionEntity.Created, + Renewed = sessionEntity.Renewed, + Expires = sessionEntity.Expires, + Data = sessionEntity.Data + }; + } + } + + /// + /// Mapping extension methods for + /// + /// The model. + extension(Models.IdentityServerServerSideSessions sessionModel) + { + /// + /// Mapper for to convert into an instance of + /// + /// mapped instance of + public Entities.IdentityServerServerSideSessions ToEntity() + { + return new Entities.IdentityServerServerSideSessions + { + Key = sessionModel.Key, + Scheme = sessionModel.Scheme, + SubjectId = sessionModel.SubjectId, + SessionId = sessionModel.SessionId, + DisplayName = sessionModel.DisplayName, + Created = sessionModel.Created, + Renewed = sessionModel.Renewed, + Expires = sessionModel.Expires, + Data = sessionModel.Data + }; + } + + /// + /// Updates with instance of + /// + /// The entity. + public void UpdateEntity(Entities.IdentityServerServerSideSessions existingEntity) + { + existingEntity.Key = sessionModel.Key; + existingEntity.Scheme = sessionModel.Scheme; + existingEntity.SubjectId = sessionModel.SubjectId; + existingEntity.SessionId = sessionModel.SessionId; + existingEntity.DisplayName = sessionModel.DisplayName; + existingEntity.Created = sessionModel.Created; + existingEntity.Renewed = sessionModel.Renewed; + existingEntity.Expires = sessionModel.Expires; + existingEntity.Data = sessionModel.Data; + } + } +} \ No newline at end of file diff --git a/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs b/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs new file mode 100644 index 000000000..e887d8276 --- /dev/null +++ b/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs @@ -0,0 +1,115 @@ +// 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.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Open.IdentityServer.EntityFramework.Interfaces; +using Open.IdentityServer.EntityFramework.Mappers; +using Open.IdentityServer.Stores; +using IdentityServerServerSideSessions = Open.IdentityServer.Models.IdentityServerServerSideSessions; + +namespace Open.IdentityServer.EntityFramework.Stores; + +/// +/// Storage and retrieval of server side sessions using entity framework core +/// +public class IdentityServerServerSideSessionStore( + IPersistedGrantDbContext dbContext, + ILogger logger): IIdentityServerServerSideSessionStore +{ + /// + public async Task GetSession(string key) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + Entities.IdentityServerServerSideSessions? session = await dbContext.ServerSideSessions + .SingleOrDefaultAsync(x => x.Key == key); + + return session?.ToModel(); + } + + /// + public async Task CreateSession(IdentityServerServerSideSessions session) + { + ArgumentException.ThrowIfNullOrWhiteSpace(session.Key); + + Entities.IdentityServerServerSideSessions? existing = await dbContext.ServerSideSessions + .SingleOrDefaultAsync(x => x.Key == session.Key); + + if (existing != null) + { + logger.LogError("failed storing '{SessionKey}' session in database, session with key already exists", session.Key); + return; + } + + Entities.IdentityServerServerSideSessions sessionEntity = session.ToEntity(); + + await dbContext.ServerSideSessions.AddAsync(sessionEntity); + + try + { + await dbContext.SaveChangesAsync(); + } + catch (Exception ex) + { + logger.LogError(ex, "exception storing '{SessionKey}' session in database", session.Key); + } + } + + /// + public async Task UpdateSession(IdentityServerServerSideSessions session) + { + ArgumentException.ThrowIfNullOrWhiteSpace(session.Key); + + Entities.IdentityServerServerSideSessions? existing = await dbContext.ServerSideSessions + .SingleOrDefaultAsync(x => x.Key == session.Key); + + if (existing == null) + { + logger.LogError("failed updating '{SessionKey}' session in database, session not found", session.Key); + return; + } + + session.UpdateEntity(existing); + + try + { + await dbContext.SaveChangesAsync(); + } + catch (Exception ex) + { + logger.LogError(ex, "exception updating '{SessionKey}' session in database", session.Key); + } + } + + /// + public async Task DeleteSession(string key) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + Entities.IdentityServerServerSideSessions? existing = await dbContext.ServerSideSessions + .SingleOrDefaultAsync(x => x.Key == key); + + if (existing == null) + { + logger.LogError("failed deleting '{SessionKey}' session in database, session not found", key); + return; + } + + dbContext.ServerSideSessions.Remove(existing); + + try + { + await dbContext.SaveChangesAsync(); + } + catch (Exception ex) + { + logger.LogError(ex, "exception deleting '{SessionKey}' session in database", key); + } + } +} \ No newline at end of file diff --git a/src/EntityFramework.Storage/test/IntegrationTests/MockLogger.cs b/src/EntityFramework.Storage/test/IntegrationTests/MockLogger.cs new file mode 100644 index 000000000..de10460c2 --- /dev/null +++ b/src/EntityFramework.Storage/test/IntegrationTests/MockLogger.cs @@ -0,0 +1,47 @@ +using System; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Open.IdentityServer.EntityFramework.IntegrationTests; + +public class MockLogger : ILogger +{ + private readonly ILogger _mock = Mock.Of>(); + + public static FakeLogger Create() => new(); + + public IDisposable? BeginScope(TState state) where TState : notnull + => _mock.BeginScope(state); + + public bool IsEnabled(LogLevel logLevel) + => _mock.IsEnabled(logLevel); + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + => _mock.Log(logLevel, eventId, state, exception, formatter); + + public void VerifyLog(LogLevel level, string message, Times? times = null) + { + Mock.Get(_mock) + .Verify( + x => x.Log( + level, + It.IsAny(), + It.Is((v, _) => v.ToString()!.Contains(message)), + It.IsAny(), + It.IsAny>()), + times ?? Times.Once()); + } + + public void VerifyLog(LogLevel level, Times? times = null) + { + Mock.Get(_mock) + .Verify( + x => x.Log( + level, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), + times ?? Times.Once()); + } +} \ No newline at end of file diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs new file mode 100644 index 000000000..9bdafcce9 --- /dev/null +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs @@ -0,0 +1,322 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using Open.IdentityServer.EntityFramework.DbContexts; +using Open.IdentityServer.EntityFramework.Entities; +using Open.IdentityServer.EntityFramework.Options; +using Open.IdentityServer.EntityFramework.Stores; +using Xunit; +using SessionModel = Open.IdentityServer.Models.IdentityServerServerSideSessions; + +namespace Open.IdentityServer.EntityFramework.IntegrationTests.Stores.Compatibility; + +public class IdentityServerServerSideSessionStoreTests: IntegrationTest +{ + private readonly MockLogger fakeLogger = new(); + + public IdentityServerServerSideSessionStoreTests(DatabaseProviderFixture fixture) : base(fixture) + { + foreach (var row in TestDatabaseProviders) + { + using var context = new PersistedGrantDbContext(row.Data, StoreOptions); + context.Database.EnsureCreated(); + } + } + + private IdentityServerServerSideSessionStore CreateSut(PersistedGrantDbContext dbContext) => + new(dbContext, fakeLogger); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task GetSession_WhenKeyNullOrEmpty_ShouldThrowArgumentException(string key) + { + await using var context = await CreateCleanContext(TestDatabaseProviders.FirstOrDefault()); + var sut = CreateSut(context); + + Func act = async () => await sut.GetSession(key); + + await act.Should().ThrowAsync(); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task GetSession_WhenDoesntExist_ShouldReturnNull(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + var sut = CreateSut(context); + + var result = await sut.GetSession("missing-key"); + + result.Should().BeNull(); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task GetSession_WhenExist_ShouldReturnValue(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + + var key = "session-key-1"; + var seeded = new IdentityServerServerSideSessions + { + Key = key, + Scheme = "cookie", + SubjectId = "sub-1", + SessionId = "sid-1", + DisplayName = "display-1", + Created = DateTime.UtcNow.AddMinutes(-10), + Renewed = DateTime.UtcNow.AddMinutes(-5), + Expires = DateTime.UtcNow.AddMinutes(30), + Data = "{\"foo\":\"bar\"}" + }; + + context.ServerSideSessions.Add(seeded); + await context.SaveChangesAsync(); + + var sut = CreateSut(context); + + var result = await sut.GetSession(key); + + result.Should().NotBeNull(); + result!.Key.Should().Be(seeded.Key); + result.Scheme.Should().Be(seeded.Scheme); + result.SubjectId.Should().Be(seeded.SubjectId); + result.SessionId.Should().Be(seeded.SessionId); + result.DisplayName.Should().Be(seeded.DisplayName); + result.Created.Should().Be(seeded.Created); + result.Renewed.Should().Be(seeded.Renewed); + result.Expires.Should().Be(seeded.Expires); + result.Data.Should().Be(seeded.Data); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task CreateSession_WhenKeyNullOrEmpty_ShouldThrowArgumentException(string key) + { + await using var context = await CreateCleanContext(TestDatabaseProviders.FirstOrDefault()); + var sut = CreateSut(context); + + var newSession = BuildSessionModel(key, "sub-new", "sid-new", "new"); + + Func act = async () => await sut.CreateSession(newSession); + + await act.Should().ThrowAsync(); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task CreateSession_WhenSessionAlreadyExistsWithKey_ShouldLogError(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + + var key = "duplicate-key"; + context.ServerSideSessions.Add(new IdentityServerServerSideSessions + { + Key = key, + Scheme = "cookie", + SubjectId = "sub-existing", + SessionId = "sid-existing", + DisplayName = "existing", + Created = DateTime.UtcNow.AddMinutes(-20), + Renewed = DateTime.UtcNow.AddMinutes(-10), + Expires = DateTime.UtcNow.AddMinutes(20), + Data = "{\"state\":\"existing\"}" + }); + await context.SaveChangesAsync(); + + var sut = CreateSut(context); + var newSession = BuildSessionModel(key, "sub-new", "sid-new", "new"); + + await sut.CreateSession(newSession); + + fakeLogger.VerifyLog(LogLevel.Error, Times.AtLeastOnce()); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task CreateSession_WhenSessionDoesntExistsWithKey_ShouldStoreSessionInDatabase(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + + var key = "new-key"; + var session = BuildSessionModel(key, "sub-123", "sid-123", "display-123"); + + var sut = CreateSut(context); + + await sut.CreateSession(session); + + var stored = await context.ServerSideSessions + .AsNoTracking() + .SingleOrDefaultAsync(x => x.Key == key, cancellationToken: TestContext.Current.CancellationToken); + + stored.Should().NotBeNull(); + stored!.Key.Should().Be(session.Key); + stored.Scheme.Should().Be(session.Scheme); + stored.SubjectId.Should().Be(session.SubjectId); + stored.SessionId.Should().Be(session.SessionId); + stored.DisplayName.Should().Be(session.DisplayName); + stored.Created.Should().Be(session.Created); + stored.Renewed.Should().Be(session.Renewed); + stored.Expires.Should().Be(session.Expires); + stored.Data.Should().Be(session.Data); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task UpdateSession_WhenKeyNullOrEmpty_ShouldThrowArgumentException(string key) + { + await using var context = await CreateCleanContext(TestDatabaseProviders.FirstOrDefault()); + var sut = CreateSut(context); + + var session = BuildSessionModel(key, "sub-new", "sid-new", "new"); + + Func act = async () => await sut.UpdateSession(session); + + await act.Should().ThrowAsync(); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task UpdateSession_WhenSessionDoesntExistsWithKey_ShouldLogError(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + var sut = CreateSut(context); + + var session = BuildSessionModel("missing-update-key", "sub", "sid", "display"); + + await sut.UpdateSession(session); + + fakeLogger.VerifyLog(LogLevel.Error, Times.AtLeastOnce()); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task UpdateSession_WhenSessionExistsWithKey_ShouldUpdateStoredSession(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + + var key = "update-key"; + context.ServerSideSessions.Add(new IdentityServerServerSideSessions + { + Key = key, + Scheme = "old-scheme", + SubjectId = "old-sub", + SessionId = "old-sid", + DisplayName = "old-display", + Created = DateTime.UtcNow.AddHours(-2), + Renewed = DateTime.UtcNow.AddHours(-1), + Expires = DateTime.UtcNow.AddMinutes(5), + Data = "{\"version\":1}" + }); + await context.SaveChangesAsync(); + + var updated = BuildSessionModel(key, "new-sub", "new-sid", "new-display"); + updated.Scheme = "new-scheme"; + updated.Data = "{\"version\":2}"; + updated.Created = DateTime.UtcNow.AddHours(-3); + updated.Renewed = DateTime.UtcNow.AddMinutes(-1); + updated.Expires = DateTime.UtcNow.AddHours(2); + + var sut = CreateSut(context); + + await sut.UpdateSession(updated); + + var stored = await context.ServerSideSessions + .AsNoTracking() + .SingleAsync(x => x.Key == key, cancellationToken: TestContext.Current.CancellationToken); + + stored.Key.Should().Be(updated.Key); + stored.Scheme.Should().Be(updated.Scheme); + stored.SubjectId.Should().Be(updated.SubjectId); + stored.SessionId.Should().Be(updated.SessionId); + stored.DisplayName.Should().Be(updated.DisplayName); + stored.Created.Should().Be(updated.Created); + stored.Renewed.Should().Be(updated.Renewed); + stored.Expires.Should().Be(updated.Expires); + stored.Data.Should().Be(updated.Data); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task DeleteSession_WhenKeyNullOrEmpty_ShouldThrowArgumentException(string key) + { + await using var context = await CreateCleanContext(TestDatabaseProviders.FirstOrDefault()); + var sut = CreateSut(context); + + Func act = async () => await sut.DeleteSession(key); + + await act.Should().ThrowAsync(); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task DeleteSession_WhenSessionDoesntExistsWithKey_ShouldLogError(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + var sut = CreateSut(context); + + await sut.DeleteSession("missing-delete-key"); + + fakeLogger.VerifyLog(LogLevel.Error, Times.AtLeastOnce()); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task DeleteSession_WhenSessionExistsWithKey_ShouldDeleteStoredSession(DbContextOptions options) + { + await using var context = await CreateCleanContext(options); + + var key = "delete-key"; + context.ServerSideSessions.Add(new IdentityServerServerSideSessions + { + Key = key, + Scheme = "cookie", + SubjectId = "sub-delete", + SessionId = "sid-delete", + DisplayName = "delete me", + Created = DateTime.UtcNow.AddMinutes(-30), + Renewed = DateTime.UtcNow.AddMinutes(-15), + Expires = DateTime.UtcNow.AddMinutes(30), + Data = "{\"delete\":true}" + }); + await context.SaveChangesAsync(); + + var sut = CreateSut(context); + + await sut.DeleteSession(key); + + var stored = await context.ServerSideSessions + .AsNoTracking() + .SingleOrDefaultAsync(x => x.Key == key, cancellationToken: TestContext.Current.CancellationToken); + + stored.Should().BeNull(); + } + + private async Task CreateCleanContext(DbContextOptions options) + { + var context = new PersistedGrantDbContext(options, StoreOptions); + await context.Database.EnsureDeletedAsync(); + await context.Database.EnsureCreatedAsync(); + return context; + } + + private static SessionModel BuildSessionModel(string key, string subjectId, string sessionId, string displayName) + { + return new SessionModel + { + Key = key, + Scheme = "cookie", + SubjectId = subjectId, + SessionId = sessionId, + DisplayName = displayName, + Created = DateTime.UtcNow.AddMinutes(-10), + Renewed = DateTime.UtcNow.AddMinutes(-5), + Expires = DateTime.UtcNow.AddHours(1), + Data = "{\"payload\":\"value\"}" + }; + } +} \ No newline at end of file diff --git a/src/Storage/src/Models/Compatibility/IdentityServerServerSideSessions.cs b/src/Storage/src/Models/Compatibility/IdentityServerServerSideSessions.cs new file mode 100644 index 000000000..91b30c661 --- /dev/null +++ b/src/Storage/src/Models/Compatibility/IdentityServerServerSideSessions.cs @@ -0,0 +1,54 @@ +using System; + +namespace Open.IdentityServer.Models; + +/// +/// A model for Server Side Sessions stored in a IdentityServer database +/// +public class IdentityServerServerSideSessions +{ + /// + /// Get or set key + /// + public string Key { get; set; } = null!; + + /// + /// Get or set scheme + /// + public string Scheme { get; set; } = null!; + + /// + /// Get or set subject identifier + /// + public string SubjectId { get; set; } = null!; + + /// + /// Get or set session identifier + /// + public string? SessionId { get; set; } + + /// + /// Get or set display name + /// + public string? DisplayName { get; set; } + + /// + /// Get or set created datetime + /// + public DateTime Created { get; set; } + + /// + /// Get or set renewed datetime + /// + public DateTime Renewed { get; set; } + + /// + /// Get or set expires datetime + /// + public DateTime? Expires { get; set; } + + /// + /// Get or set data value + /// + public string Data { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs b/src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs new file mode 100644 index 000000000..8eb7d412b --- /dev/null +++ b/src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs @@ -0,0 +1,43 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +#nullable enable + +using System.Threading.Tasks; +using Open.IdentityServer.Models; + +namespace Open.IdentityServer.Stores; + +/// +/// Storage and retrieval of server server side sessions +/// +public interface IIdentityServerServerSideSessionStore +{ + /// + /// Gets server side session using unique key + /// + /// unique key of session + /// found session or null if not found + public Task GetSession(string key); + + /// + /// Stores the provides session model, must have a unique key set + /// + /// session model to store + /// void + public Task CreateSession(IdentityServerServerSideSessions session); + + /// + /// Updates the provided server side session model, model with unique key must already exist in store + /// + /// session model to update + /// void + public Task UpdateSession(IdentityServerServerSideSessions session); + + /// + /// Deletes server side session using unique key + /// + /// unique key of session + /// void + public Task DeleteSession(string key); +} \ No newline at end of file From c13e871839a7bb2c16ea59b4f12997f8b57f53cf Mon Sep 17 00:00:00 2001 From: James Britton Date: Tue, 21 Jul 2026 13:31:56 +0100 Subject: [PATCH 02/59] feat: implement mapping auth ticket to a serialisable form --- .../src/DataProtectionConstants.cs | 3 ++ .../AuthenticationTicketSerialiser.cs | 23 ++++++++++ .../src/Extensions/ClaimsExtensions.cs | 13 +++++- .../Extensions/ClaimsPrincipleExtension.cs | 22 ++++++++++ .../Default/ServerSessionTicketStore.cs | 43 +++++++++++++++++++ .../Default/ServerSessionTicketStoreTests.cs | 21 +++++++++ .../src/Stores/Serialization/ClaimLite.cs | 2 + .../SerializedAuthenticationTicket.cs | 24 +++++++++++ 8 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 src/Open.IdentityServer/src/Extensions/AuthenticationTicketSerialiser.cs create mode 100644 src/Open.IdentityServer/src/Extensions/ClaimsPrincipleExtension.cs create mode 100644 src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/ServerSessionTicketStoreTests.cs create mode 100644 src/Storage/src/Stores/Serialization/SerializedAuthenticationTicket.cs diff --git a/src/Open.IdentityServer/src/DataProtectionConstants.cs b/src/Open.IdentityServer/src/DataProtectionConstants.cs index 9e0485dcf..01496c851 100644 --- a/src/Open.IdentityServer/src/DataProtectionConstants.cs +++ b/src/Open.IdentityServer/src/DataProtectionConstants.cs @@ -10,4 +10,7 @@ public static class DataProtectionConstants { /// Purpose used when creating key material data protector. public const string KeyProtectorPurpose = "DataProtectionKeyProtector"; + + /// Purpose used when creating server side ticket store data protector. + public const string ServerSideTicketStorePurpose = "Duende.SessionManagement.ServerSideTicketStore"; } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Extensions/AuthenticationTicketSerialiser.cs b/src/Open.IdentityServer/src/Extensions/AuthenticationTicketSerialiser.cs new file mode 100644 index 000000000..4a2be5f66 --- /dev/null +++ b/src/Open.IdentityServer/src/Extensions/AuthenticationTicketSerialiser.cs @@ -0,0 +1,23 @@ +// 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 Microsoft.AspNetCore.Authentication; +using Open.IdentityServer.Stores.Serialization; + +namespace Open.IdentityServer.Extensions; + +internal static class AuthenticationTicketExtensions +{ + extension(AuthenticationTicket authenticationTicket) + { + public SerializedAuthenticationTicket ToSerializableObj() + { + return new SerializedAuthenticationTicket + { + Scheme = authenticationTicket.AuthenticationScheme, + User = authenticationTicket.Principal.ToSerializableObj(), + Items = authenticationTicket.Properties.Items, + }; + } + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Extensions/ClaimsExtensions.cs b/src/Open.IdentityServer/src/Extensions/ClaimsExtensions.cs index 23604f338..a2ecd84ee 100644 --- a/src/Open.IdentityServer/src/Extensions/ClaimsExtensions.cs +++ b/src/Open.IdentityServer/src/Extensions/ClaimsExtensions.cs @@ -1,12 +1,13 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. - using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; using System.Text.Json; +using Open.IdentityServer.Stores.Serialization; namespace Open.IdentityServer.Extensions; @@ -79,11 +80,19 @@ private static object GetValue(Claim claim) { try { - return System.Text.Json.JsonSerializer.Deserialize(claim.Value); + return JsonSerializer.Deserialize(claim.Value); } catch { } } return claim.Value; } + + public static ClaimLite[] ToSerializableObj(this IEnumerable claims) + { + return claims.Select(x => new ClaimLite + { + Type = x.Type, Value = x.Value, ValueType = x.ValueType, Issuer = x.Issuer, + }).ToArray(); + } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Extensions/ClaimsPrincipleExtension.cs b/src/Open.IdentityServer/src/Extensions/ClaimsPrincipleExtension.cs new file mode 100644 index 000000000..59212d647 --- /dev/null +++ b/src/Open.IdentityServer/src/Extensions/ClaimsPrincipleExtension.cs @@ -0,0 +1,22 @@ +// 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.Security.Claims; +using Open.IdentityServer.Stores.Serialization; + +namespace Open.IdentityServer.Extensions; + +internal static class ClaimsPrincipleExtension +{ + extension(ClaimsPrincipal claimsPrincipal) + { + public ClaimsPrincipalLite ToSerializableObj() + { + return new ClaimsPrincipalLite + { + AuthenticationType = claimsPrincipal.Identity!.AuthenticationType!, + Claims = claimsPrincipal.Claims.ToSerializableObj(), + }; + } + } +} \ 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 new file mode 100644 index 000000000..fe8357b19 --- /dev/null +++ b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs @@ -0,0 +1,43 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging; + +namespace Open.IdentityServer.Stores; + +/// +/// +/// +/// +/// +public class ServerSessionTicketStore( + IDataProtectionProvider dataProtectionProvider, + ILogger logger): ITicketStore +{ + private IDataProtector dataProtector = dataProtectionProvider?.CreateProtector(DataProtectionConstants.ServerSideTicketStorePurpose); + + /// + public Task StoreAsync(AuthenticationTicket ticket) + { + throw new System.NotImplementedException(); + } + + /// + public Task RenewAsync(string key, AuthenticationTicket ticket) + { + throw new System.NotImplementedException(); + } + + /// + public Task RetrieveAsync(string key) + { + throw new System.NotImplementedException(); + } + + /// + public Task RemoveAsync(string key) + { + throw new System.NotImplementedException(); + } +} \ 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 new file mode 100644 index 000000000..1bf9fcdf5 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/ServerSessionTicketStoreTests.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging; +using Moq; +using Open.IdentityServer.Stores; +using Xunit; + +namespace IdentityServer.UnitTests.Stores.Default; + +public class ServerSessionTicketStoreTests +{ + private readonly IDataProtectionProvider dataProtectionProvider = Mock.Of(); + private readonly ILogger logger; + + private ServerSessionTicketStore CreateSut() => new(dataProtectionProvider, logger); + + [Fact] + public void _When_Should() + { + + } +} \ No newline at end of file diff --git a/src/Storage/src/Stores/Serialization/ClaimLite.cs b/src/Storage/src/Stores/Serialization/ClaimLite.cs index cc11fb55f..af3f97912 100644 --- a/src/Storage/src/Stores/Serialization/ClaimLite.cs +++ b/src/Storage/src/Stores/Serialization/ClaimLite.cs @@ -16,4 +16,6 @@ public class ClaimLite public string Value { get; set; } /// Gets or sets the claim value type. public string ValueType { get; set; } + /// Gets or sets the claim issuer. + public string Issuer { get; set; } } \ No newline at end of file diff --git a/src/Storage/src/Stores/Serialization/SerializedAuthenticationTicket.cs b/src/Storage/src/Stores/Serialization/SerializedAuthenticationTicket.cs new file mode 100644 index 000000000..33abc4703 --- /dev/null +++ b/src/Storage/src/Stores/Serialization/SerializedAuthenticationTicket.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace Open.IdentityServer.Stores.Serialization; + +/// +/// Model for serialized authentication ticket +/// +public class SerializedAuthenticationTicket +{ + /// + /// The authentication scheme + /// + public string Scheme { get; init; } = null!; + + /// + /// The authenticated user + /// + public ClaimsPrincipalLite User { get; init; } = null!; + + /// + /// The property items + /// + public IDictionary Items { get; init; } = null!; +} \ No newline at end of file From 7fe083c3fb0405885d2890a815d9a42f85e403fb Mon Sep 17 00:00:00 2001 From: James Britton Date: Wed, 22 Jul 2026 10:40:01 +0100 Subject: [PATCH 03/59] feat: implementing ITicketStore implementation --- .../test/IntegrationTests/MockLogger.cs | 2 +- .../AuthenticationTicketSerialiser.cs | 13 + .../src/Extensions/ClaimsExtensions.cs | 5 + .../Extensions/ClaimsPrincipleExtension.cs | 8 + .../Default/ServerSessionTicketStore.cs | 98 ++++- .../MockDataProtector.cs | 41 +++ .../Open.IdentityServer.UnitTests.csproj | 6 + .../Default/ServerSessionTicketStoreTests.cs | 344 +++++++++++++++++- 8 files changed, 505 insertions(+), 12 deletions(-) create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/MockDataProtector.cs diff --git a/src/EntityFramework.Storage/test/IntegrationTests/MockLogger.cs b/src/EntityFramework.Storage/test/IntegrationTests/MockLogger.cs index de10460c2..a40ddf389 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/MockLogger.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/MockLogger.cs @@ -8,7 +8,7 @@ public class MockLogger : ILogger { private readonly ILogger _mock = Mock.Of>(); - public static FakeLogger Create() => new(); + public static MockLogger Create() => new(); public IDisposable? BeginScope(TState state) where TState : notnull => _mock.BeginScope(state); diff --git a/src/Open.IdentityServer/src/Extensions/AuthenticationTicketSerialiser.cs b/src/Open.IdentityServer/src/Extensions/AuthenticationTicketSerialiser.cs index 4a2be5f66..a9e6b2ef6 100644 --- a/src/Open.IdentityServer/src/Extensions/AuthenticationTicketSerialiser.cs +++ b/src/Open.IdentityServer/src/Extensions/AuthenticationTicketSerialiser.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. +using System.Linq; +using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using Open.IdentityServer.Stores.Serialization; @@ -20,4 +22,15 @@ public SerializedAuthenticationTicket ToSerializableObj() }; } } + + extension(SerializedAuthenticationTicket serializationAuthTicket) + { + public AuthenticationTicket ToAuthTicket() + { + return new AuthenticationTicket( + serializationAuthTicket.User.ToClaimsPrincipal(), + new AuthenticationProperties(serializationAuthTicket.Items), + serializationAuthTicket.Scheme); + } + } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Extensions/ClaimsExtensions.cs b/src/Open.IdentityServer/src/Extensions/ClaimsExtensions.cs index a2ecd84ee..cb0e08c5f 100644 --- a/src/Open.IdentityServer/src/Extensions/ClaimsExtensions.cs +++ b/src/Open.IdentityServer/src/Extensions/ClaimsExtensions.cs @@ -95,4 +95,9 @@ public static ClaimLite[] ToSerializableObj(this IEnumerable claims) Type = x.Type, Value = x.Value, ValueType = x.ValueType, Issuer = x.Issuer, }).ToArray(); } + + public static Claim[] ToClaims(this ClaimLite[] claims) + { + return claims.Select(x => new Claim(x.Type, x.Value, x.ValueType, x.Issuer)).ToArray(); + } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Extensions/ClaimsPrincipleExtension.cs b/src/Open.IdentityServer/src/Extensions/ClaimsPrincipleExtension.cs index 59212d647..bae14a425 100644 --- a/src/Open.IdentityServer/src/Extensions/ClaimsPrincipleExtension.cs +++ b/src/Open.IdentityServer/src/Extensions/ClaimsPrincipleExtension.cs @@ -19,4 +19,12 @@ public ClaimsPrincipalLite ToSerializableObj() }; } } + + extension(ClaimsPrincipalLite claimsPrincipalLite) + { + public ClaimsPrincipal ToClaimsPrincipal() + { + return new ClaimsPrincipal(new ClaimsIdentity(claimsPrincipalLite.Claims.ToClaims(), claimsPrincipalLite.AuthenticationType)); + } + } } \ 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 fe8357b19..64f2e3a17 100644 --- a/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs +++ b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs @@ -1,43 +1,125 @@ +#nullable enable + +using System; +using System.Security.Claims; +using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Logging; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.Models; +using Open.IdentityServer.Stores.Serialization; namespace Open.IdentityServer.Stores; /// /// /// +/// /// +/// /// public class ServerSessionTicketStore( + IIdentityServerServerSideSessionStore serverServerSideSessionStore, IDataProtectionProvider dataProtectionProvider, + TimeProvider timeProvider, ILogger logger): ITicketStore { - private IDataProtector dataProtector = dataProtectionProvider?.CreateProtector(DataProtectionConstants.ServerSideTicketStorePurpose); + private readonly IDataProtector dataProtector = dataProtectionProvider.CreateProtector(DataProtectionConstants.ServerSideTicketStorePurpose); + + /// + /// + /// + public static readonly JsonSerializerOptions JsonSettings = new() + { + IncludeFields = true, + }; /// - public Task StoreAsync(AuthenticationTicket ticket) + public async Task StoreAsync(AuthenticationTicket ticket) { - throw new System.NotImplementedException(); + var serializedTicket = JsonSerializer.Serialize(ticket.ToSerializableObj()); + + var serverSideSession = new IdentityServerServerSideSessions + { + Key = Guid.NewGuid().ToString(), + Scheme = ticket.AuthenticationScheme, + SubjectId = ticket.Principal.GetSubjectId(), + SessionId = ticket.Properties.GetSessionId(), + DisplayName = ticket.Principal.FindFirstValue(JwtClaimTypes.Name), //Make configurable? + Created = ticket.Properties.IssuedUtc?.UtcDateTime ?? timeProvider.GetUtcNow().UtcDateTime, + Renewed = ticket.Properties.IssuedUtc?.UtcDateTime ?? timeProvider.GetUtcNow().UtcDateTime, + Expires = ticket.Properties.ExpiresUtc?.UtcDateTime, + Data = dataProtector.Protect(serializedTicket), + }; + + await serverServerSideSessionStore.CreateSession(serverSideSession); + + return serverSideSession.Key; } /// - public Task RenewAsync(string key, AuthenticationTicket ticket) + public async Task RenewAsync(string key, AuthenticationTicket ticket) { - throw new System.NotImplementedException(); + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + var existingSession = await serverServerSideSessionStore.GetSession(key); + + if (existingSession == null) + { + logger.LogError("failed renewing '{SessionKey}' session in database, session with key doesn't exists", key); + return; + } + + var serializedTicket = JsonSerializer.Serialize(ticket.ToSerializableObj()); + + existingSession.Scheme = ticket.AuthenticationScheme; + existingSession.SubjectId = ticket.Principal.GetSubjectId(); + existingSession.SessionId = ticket.Properties.GetSessionId(); + existingSession.DisplayName = ticket.Principal.FindFirstValue(JwtClaimTypes.Name); + existingSession.Renewed = ticket.Properties.IssuedUtc?.UtcDateTime ?? timeProvider.GetUtcNow().UtcDateTime; + existingSession.Expires = ticket.Properties.ExpiresUtc?.UtcDateTime; + existingSession.Data = dataProtector.Protect(serializedTicket); + + await serverServerSideSessionStore.UpdateSession(existingSession); } /// - public Task RetrieveAsync(string key) + public async Task RetrieveAsync(string key) { - throw new System.NotImplementedException(); + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + var existingSession = await serverServerSideSessionStore.GetSession(key); + + if (existingSession == null) + { + logger.LogInformation("session with key '{SessionKey}' doesn't exist", key); + return null; + } + + try + { + var unprotectedData = dataProtector.Unprotect(existingSession.Data); + + SerializedAuthenticationTicket? serializedAuthTicket = JsonSerializer.Deserialize(unprotectedData); + + return serializedAuthTicket?.ToAuthTicket(); + } + catch (Exception ex) + { + logger.LogError(ex, "failed retrieving '{SessionKey}' session in database", key); + return null; + } } /// public Task RemoveAsync(string key) { - throw new System.NotImplementedException(); + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + serverServerSideSessionStore.DeleteSession(key); + return Task.CompletedTask; } } \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/MockDataProtector.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/MockDataProtector.cs new file mode 100644 index 000000000..c1b21e908 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/MockDataProtector.cs @@ -0,0 +1,41 @@ +using System.Text; +using AwesomeAssertions; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.WebUtilities; +using Moq; + +namespace Open.IdentityServer.UnitTests; + +public class MockDataProtector: IDataProtector +{ + public IDataProtector dataProtector = Mock.Of(); + public static readonly UTF8Encoding UTF8Encoding = new(false, true); + public const string ProtectedPrefix = "PROTECTED--"; + + public MockDataProtector() + { + Mock.Get(dataProtector) + .Setup(x => x.Protect(It.IsAny())) + .Returns((plaintext) => [..UTF8Encoding.GetBytes(ProtectedPrefix), ..plaintext]); + + Mock.Get(dataProtector) + .Setup(x => x.Unprotect(It.IsAny())) + .Returns((protectedData) => UTF8Encoding.GetBytes(UTF8Encoding.GetString(protectedData).Replace(ProtectedPrefix, string.Empty))); + } + + public IDataProtector CreateProtector(string purpose) => dataProtector; + + public byte[] Protect(byte[] plaintext) => dataProtector.Protect(plaintext); + + public byte[] Unprotect(byte[] protectedData) => dataProtector.Unprotect(protectedData); + + public void ValidateProtectedData(string protectedData, string originalString) + { + var unencodedProtectedData = UTF8Encoding.GetString(WebEncoders.Base64UrlDecode(protectedData)); + unencodedProtectedData = unencodedProtectedData.Replace(ProtectedPrefix, string.Empty); + unencodedProtectedData.Should().BeEquivalentTo(originalString); + } + + public string GenerateFakeProtectedData(string data) => + WebEncoders.Base64UrlEncode(UTF8Encoding.GetBytes(ProtectedPrefix + data)); +} \ No newline at end of file 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 46644c63d..172649d7a 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 @@ -38,4 +38,10 @@ + + + + MockLogger.cs + + 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 1bf9fcdf5..b1c5c38a1 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 @@ -1,21 +1,359 @@ +// 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 System.Text.Json; +using System.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Time.Testing; using Moq; +using Open.IdentityServer; +using Open.IdentityServer.EntityFramework.IntegrationTests; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.Models; using Open.IdentityServer.Stores; +using Open.IdentityServer.Stores.Serialization; +using Open.IdentityServer.UnitTests; using Xunit; namespace IdentityServer.UnitTests.Stores.Default; public class ServerSessionTicketStoreTests { + private readonly IIdentityServerServerSideSessionStore serverServerSideSessionStore = Mock.Of(); private readonly IDataProtectionProvider dataProtectionProvider = Mock.Of(); - private readonly ILogger logger; + private readonly MockDataProtector dataProtector = new(); + private readonly FakeTimeProvider fakeTimeProvider = new(); + private readonly MockLogger logger = new(); + + private static readonly DateTime FakeNow = new(2026, 01, 01, 12, 0, 0, DateTimeKind.Utc); + + public ServerSessionTicketStoreTests() + { + fakeTimeProvider.SetUtcNow(FakeNow); + + Mock.Get(dataProtectionProvider) + .Setup(x => x.CreateProtector(DataProtectionConstants.ServerSideTicketStorePurpose)) + .Returns(dataProtector); + } + + private ServerSessionTicketStore CreateSut() => new(serverServerSideSessionStore, dataProtectionProvider, fakeTimeProvider, logger); + + [Fact] + public async Task StoreAsync_WhenOptionalValuesNotProvided_ShouldUseCorrectDefaults() + { + const string authScheme = "FakeAuthScheme"; + string subjectId = Guid.NewGuid().ToString(); + string sessionId = Guid.NewGuid().ToString(); + + AuthenticationTicket authenticationTicket = GenerateAuthenticationTicket(authScheme, subjectId, sessionId); + + 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().BeNull(); + createdSessionModel.Created.Should().Be(FakeNow); + createdSessionModel.Renewed.Should().Be(FakeNow); + createdSessionModel.Expires.Should().BeNull(); + + string expectedJson = JsonSerializer.Serialize(authenticationTicket.ToSerializableObj(), ServerSessionTicketStore.JsonSettings); + dataProtector.ValidateProtectedData(createdSessionModel.Data, expectedJson); + } + + [Fact] + public async Task StoreAsync_WhenOptionalValuesProvided_ShouldUseThem() + { + 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); + + AuthenticationTicket authenticationTicket = 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(displayName); + createdSessionModel.Created.Should().Be(issuedUtc); + createdSessionModel.Renewed.Should().Be(issuedUtc); + createdSessionModel.Expires.Should().Be(expiresUtc); + + string expectedJson = JsonSerializer.Serialize(authenticationTicket.ToSerializableObj(), ServerSessionTicketStore.JsonSettings); + dataProtector.ValidateProtectedData(createdSessionModel.Data, expectedJson); + } + + [Fact] + public async Task RenewAsync_WhenNoSessionWithKey_ShouldLogError() + { + const string authScheme = "FakeAuthScheme"; + string subjectId = Guid.NewGuid().ToString(); + string sessionId = Guid.NewGuid().ToString(); + + AuthenticationTicket authenticationTicket = GenerateAuthenticationTicket(authScheme, subjectId, sessionId); + + ServerSessionTicketStore sut = CreateSut(); + + await sut.RenewAsync("non-existent-session", authenticationTicket); + logger.VerifyLog(LogLevel.Error, Times.Once()); + } + + [Fact] + public async Task RenewAsync_WhenOptionalValuesNotProvided_ShouldUseCorrectDefaults() + { + IdentityServerServerSideSessions existingSession = new IdentityServerServerSideSessions + { + Key = Guid.NewGuid().ToString(), Scheme = "AuthScheme", SessionId = Guid.NewGuid().ToString(), + SubjectId = Guid.NewGuid().ToString(), DisplayName = "John Doe", + 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), + Data = "EXISTING_PROTEXTEDDAAT", + }; + + const string authScheme = "FakeAuthScheme"; + string subjectId = Guid.NewGuid().ToString(); + string sessionId = Guid.NewGuid().ToString(); + + AuthenticationTicket authenticationTicket = GenerateAuthenticationTicket(authScheme, subjectId, sessionId); + + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.GetSession(existingSession.Key)) + .ReturnsAsync(existingSession); + + IdentityServerServerSideSessions? createdSessionModel = null; + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.UpdateSession(It.IsAny())) + .Callback((session) => { createdSessionModel = session; }); + + ServerSessionTicketStore sut = CreateSut(); + + await sut.RenewAsync(existingSession.Key, authenticationTicket); + + createdSessionModel.Should().NotBeNull(); + createdSessionModel.Key.Should().NotBeNullOrWhiteSpace(); + createdSessionModel.Key.Should().Be(existingSession.Key); + createdSessionModel.Scheme.Should().Be(authScheme); + createdSessionModel.SessionId.Should().Be(sessionId); + createdSessionModel.SubjectId.Should().Be(subjectId); + createdSessionModel.DisplayName.Should().BeNull(); + createdSessionModel.Created.Should().Be(existingSession.Created); + createdSessionModel.Renewed.Should().Be(FakeNow); + createdSessionModel.Expires.Should().BeNull(); + + string expectedJson = JsonSerializer.Serialize(authenticationTicket.ToSerializableObj(), ServerSessionTicketStore.JsonSettings); + dataProtector.ValidateProtectedData(createdSessionModel.Data, expectedJson); + } + + [Fact] + public async Task RenewAsync_WhenOptionalValuesProvided_ShouldUseThem() + { + IdentityServerServerSideSessions existingSession = new IdentityServerServerSideSessions + { + Key = Guid.NewGuid().ToString(), Scheme = "AuthScheme", SessionId = Guid.NewGuid().ToString(), + SubjectId = Guid.NewGuid().ToString(), DisplayName = "John Doe", + 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), + Data = "EXISTING_PROTEXTEDDAAT", + }; + + 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); + + AuthenticationTicket authenticationTicket = GenerateAuthenticationTicket(authScheme, subjectId, sessionId, displayName, issuedUtc, expiresUtc); + + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.GetSession(existingSession.Key)) + .ReturnsAsync(existingSession); - private ServerSessionTicketStore CreateSut() => new(dataProtectionProvider, logger); + IdentityServerServerSideSessions? createdSessionModel = null; + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.UpdateSession(It.IsAny())) + .Callback((session) => { createdSessionModel = session; }); + + ServerSessionTicketStore sut = CreateSut(); + + await sut.RenewAsync(existingSession.Key, authenticationTicket); + + createdSessionModel.Should().NotBeNull(); + createdSessionModel.Key.Should().NotBeNullOrWhiteSpace(); + createdSessionModel.Scheme.Should().Be(authScheme); + createdSessionModel.SessionId.Should().Be(sessionId); + createdSessionModel.SubjectId.Should().Be(subjectId); + createdSessionModel.DisplayName.Should().Be(displayName); + createdSessionModel.Created.Should().Be(existingSession.Created); + createdSessionModel.Renewed.Should().Be(issuedUtc); + createdSessionModel.Expires.Should().Be(expiresUtc); + + string expectedJson = JsonSerializer.Serialize(authenticationTicket.ToSerializableObj(), ServerSessionTicketStore.JsonSettings); + dataProtector.ValidateProtectedData(createdSessionModel.Data, expectedJson); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task RetrieveAsync_WhenArgumentNullOrEmpty_ShouldThrowArgumentException(string? key) + { + ServerSessionTicketStore sut = CreateSut(); + + Func act = async () => await sut.RetrieveAsync(key); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task RetrieveAsync_WhenNoSessionStoredForKey_ShouldReturnNull() + { + ServerSessionTicketStore sut = CreateSut(); + + AuthenticationTicket? actual = await sut.RetrieveAsync("non-existent-session"); + + actual.Should().BeNull(); + } + [Fact] - public void _When_Should() + public async Task RetrieveAsync_WhenSessionStoredForKey_ShouldReturnDeserializedAuthTicket() { + IdentityServerServerSideSessions existingSession = new IdentityServerServerSideSessions + { + Key = Guid.NewGuid().ToString(), Scheme = "AuthScheme", SessionId = Guid.NewGuid().ToString(), + SubjectId = Guid.NewGuid().ToString(), DisplayName = "John Doe", + 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), + }; + SerializedAuthenticationTicket authenticationTicket = GenerateSerializedAuthenticationTicket(existingSession.Scheme, existingSession.SubjectId, existingSession.SessionId, existingSession.DisplayName, existingSession.Renewed, existingSession.Expires); + existingSession.Data = dataProtector.GenerateFakeProtectedData(JsonSerializer.Serialize(authenticationTicket, ServerSessionTicketStore.JsonSettings)); + + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.GetSession(existingSession.Key)) + .ReturnsAsync(existingSession); + + ServerSessionTicketStore sut = CreateSut(); + AuthenticationTicket? actual = await sut.RetrieveAsync(existingSession.Key); + + actual.Should().BeOfType(); + actual.AuthenticationScheme.Should().Be(existingSession.Scheme); + actual.Principal.Identity?.AuthenticationType.Should().BeEquivalentTo(authenticationTicket.User.AuthenticationType); + actual.Properties.Items.Should().BeEquivalentTo(authenticationTicket.Items); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task RemoveAsync_WhenArgumentNullOrEmpty_ShouldThrowArgumentException(string? key) + { + ServerSessionTicketStore sut = CreateSut(); + + Func act = async () => await sut.RemoveAsync(key!); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task RemoveAsync_ShouldCallServerSideSessionStoreDelete() + { + string keyId = Guid.NewGuid().ToString(); + + ServerSessionTicketStore sut = CreateSut(); + await sut.RemoveAsync(keyId); + + Mock.Get(serverServerSideSessionStore) + .Verify(x => x.DeleteSession(keyId)); + } + + 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 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, + }; } } \ No newline at end of file From 9e8c19d2a38f11fd3a4d58487a8c314ded14cee4 Mon Sep 17 00:00:00 2001 From: James Britton Date: Mon, 27 Jul 2026 16:38:17 +0100 Subject: [PATCH 04/59] feat: adding cofiguration of server side sessions using extention --- ...yServerEntityFrameworkBuilderExtensions.cs | 1 + .../BuilderExtensions/Additional.cs | 16 ++++++++ ...tConfigureSessionStoreCookieAuthOptions.cs | 38 +++++++++++++++++++ .../Default/ServerSessionTicketStore.cs | 3 ++ 4 files changed, 58 insertions(+) create mode 100644 src/Open.IdentityServer/src/Configuration/PostConfigureSessionStoreCookieAuthOptions.cs diff --git a/src/EntityFramework/src/IdentityServerEntityFrameworkBuilderExtensions.cs b/src/EntityFramework/src/IdentityServerEntityFrameworkBuilderExtensions.cs index 581e7d66b..0c7cd3248 100644 --- a/src/EntityFramework/src/IdentityServerEntityFrameworkBuilderExtensions.cs +++ b/src/EntityFramework/src/IdentityServerEntityFrameworkBuilderExtensions.cs @@ -102,6 +102,7 @@ public static IIdentityServerBuilder AddOperationalStore( builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddScoped(); diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs index 425504456..534986ffa 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs @@ -10,9 +10,12 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using System; using System.Net.Http; +using Microsoft.AspNetCore.Authentication.Cookies; using Open.IdentityServer; using Open.IdentityServer.Configuration; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Open.IdentityServer.Models; namespace Microsoft.Extensions.DependencyInjection; @@ -460,4 +463,17 @@ public static IIdentityServerBuilder AddUserSession(this IIdentityServerBuild return builder; } + + /// + /// Adds server-side session storage to Open.IdentityServer + /// + /// The builder + /// The same instance so that additional calls can be chained + public static IIdentityServerBuilder AddServerSideSessions(this IIdentityServerBuilder builder) + { + builder.Services.AddSingleton, PostConfigureSessionStoreCookieAuthOptions>(); + builder.Services.AddScoped(); + + return builder; + } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Configuration/PostConfigureSessionStoreCookieAuthOptions.cs b/src/Open.IdentityServer/src/Configuration/PostConfigureSessionStoreCookieAuthOptions.cs new file mode 100644 index 000000000..3f6bd75de --- /dev/null +++ b/src/Open.IdentityServer/src/Configuration/PostConfigureSessionStoreCookieAuthOptions.cs @@ -0,0 +1,38 @@ +// 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 Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.Options; + +namespace Open.IdentityServer.Configuration; + +/// +/// IPostConfigureOptions implementation for . Registers the +/// implementation to use for storing auth tickets. +/// +/// instance of ITicketStore to use +/// Open.IdentityServer options +/// Authentication options +public class PostConfigureSessionStoreCookieAuthOptions( + ITicketStore ticketStore, + IdentityServerOptions idsOptions, + IOptions authOptions): IPostConfigureOptions +{ + /// + /// Implementation of post configure setting SessionStore parameter if + /// name provided matches scheme + /// + /// name of the scheme + /// cookie authentication options + public void PostConfigure(string name, CookieAuthenticationOptions options) + { + var scheme = idsOptions.Authentication.CookieAuthenticationScheme ?? + authOptions.Value.DefaultAuthenticateScheme ?? + authOptions.Value.DefaultScheme; + + if (scheme == name) + { + options.SessionStore = ticketStore; + } + } +} \ 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 64f2e3a17..62a11644f 100644 --- a/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs +++ b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs @@ -1,3 +1,6 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + #nullable enable using System; From 3a1778a28ff9472d9092b311ac3f3f5ac03650b7 Mon Sep 17 00:00:00 2001 From: James Britton Date: Tue, 28 Jul 2026 12:31:26 +0100 Subject: [PATCH 05/59] feat: added telemetry calls to server side session stores --- .../IdentityServerServerSideSessionStore.cs | 13 +- ...entityServerServerSideSessionStoreTests.cs | 92 ++++++--- .../Default/ServerSessionTicketStore.cs | 70 ++++--- .../Default/ServerSessionTicketStoreTests.cs | 175 ++++++++++++------ src/Storage/src/TelemetryConstants.cs | 1 + 5 files changed, 248 insertions(+), 103 deletions(-) diff --git a/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs b/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs index e887d8276..ee7eb6e5a 100644 --- a/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs +++ b/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs @@ -4,27 +4,30 @@ #nullable enable using System; -using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Open.IdentityServer.EntityFramework.Interfaces; using Open.IdentityServer.EntityFramework.Mappers; +using Open.IdentityServer.Services; using Open.IdentityServer.Stores; using IdentityServerServerSideSessions = Open.IdentityServer.Models.IdentityServerServerSideSessions; namespace Open.IdentityServer.EntityFramework.Stores; /// -/// Storage and retrieval of server side sessions using entity framework core +/// Storage and retrieval of server-side sessions using entity framework core /// public class IdentityServerServerSideSessionStore( IPersistedGrantDbContext dbContext, + ITelemetryService telemetry, ILogger logger): IIdentityServerServerSideSessionStore { /// public async Task GetSession(string key) { + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + ArgumentException.ThrowIfNullOrWhiteSpace(key); Entities.IdentityServerServerSideSessions? session = await dbContext.ServerSideSessions @@ -36,6 +39,8 @@ public class IdentityServerServerSideSessionStore( /// public async Task CreateSession(IdentityServerServerSideSessions session) { + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + ArgumentException.ThrowIfNullOrWhiteSpace(session.Key); Entities.IdentityServerServerSideSessions? existing = await dbContext.ServerSideSessions @@ -64,6 +69,8 @@ public async Task CreateSession(IdentityServerServerSideSessions session) /// public async Task UpdateSession(IdentityServerServerSideSessions session) { + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + ArgumentException.ThrowIfNullOrWhiteSpace(session.Key); Entities.IdentityServerServerSideSessions? existing = await dbContext.ServerSideSessions @@ -90,6 +97,8 @@ public async Task UpdateSession(IdentityServerServerSideSessions session) /// public async Task DeleteSession(string key) { + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + ArgumentException.ThrowIfNullOrWhiteSpace(key); Entities.IdentityServerServerSideSessions? existing = await dbContext.ServerSideSessions diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs index 9bdafcce9..b0207b40c 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using AwesomeAssertions; @@ -9,6 +10,7 @@ using Open.IdentityServer.EntityFramework.Entities; using Open.IdentityServer.EntityFramework.Options; using Open.IdentityServer.EntityFramework.Stores; +using Open.IdentityServer.Services; using Xunit; using SessionModel = Open.IdentityServer.Models.IdentityServerServerSideSessions; @@ -16,19 +18,20 @@ namespace Open.IdentityServer.EntityFramework.IntegrationTests.Stores.Compatibil public class IdentityServerServerSideSessionStoreTests: IntegrationTest { + private readonly ITelemetryService telemetry = Mock.Of(); private readonly MockLogger fakeLogger = new(); public IdentityServerServerSideSessionStoreTests(DatabaseProviderFixture fixture) : base(fixture) { - foreach (var row in TestDatabaseProviders) + foreach (TheoryDataRow> row in TestDatabaseProviders) { - using var context = new PersistedGrantDbContext(row.Data, StoreOptions); + using PersistedGrantDbContext context = new PersistedGrantDbContext(row.Data, StoreOptions); context.Database.EnsureCreated(); } } private IdentityServerServerSideSessionStore CreateSut(PersistedGrantDbContext dbContext) => - new(dbContext, fakeLogger); + new(dbContext, telemetry, fakeLogger); [Theory] [InlineData(null)] @@ -37,7 +40,7 @@ private IdentityServerServerSideSessionStore CreateSut(PersistedGrantDbContext d public async Task GetSession_WhenKeyNullOrEmpty_ShouldThrowArgumentException(string key) { await using var context = await CreateCleanContext(TestDatabaseProviders.FirstOrDefault()); - var sut = CreateSut(context); + IdentityServerServerSideSessionStore sut = CreateSut(context); Func act = async () => await sut.GetSession(key); @@ -48,9 +51,9 @@ public async Task GetSession_WhenKeyNullOrEmpty_ShouldThrowArgumentException(str public async Task GetSession_WhenDoesntExist_ShouldReturnNull(DbContextOptions options) { await using var context = await CreateCleanContext(options); - var sut = CreateSut(context); + IdentityServerServerSideSessionStore sut = CreateSut(context); - var result = await sut.GetSession("missing-key"); + SessionModel result = await sut.GetSession("missing-key"); result.Should().BeNull(); } @@ -60,8 +63,8 @@ public async Task GetSession_WhenExist_ShouldReturnValue(DbContextOptions options) { await using var context = await CreateCleanContext(options); - var sut = CreateSut(context); + IdentityServerServerSideSessionStore sut = CreateSut(context); var session = BuildSessionModel("missing-update-key", "sub", "sid", "display"); @@ -199,7 +202,7 @@ public async Task UpdateSession_WhenSessionExistsWithKey_ShouldUpdateStoredSessi { await using var context = await CreateCleanContext(options); - var key = "update-key"; + string key = "update-key"; context.ServerSideSessions.Add(new IdentityServerServerSideSessions { Key = key, @@ -221,7 +224,7 @@ public async Task UpdateSession_WhenSessionExistsWithKey_ShouldUpdateStoredSessi updated.Renewed = DateTime.UtcNow.AddMinutes(-1); updated.Expires = DateTime.UtcNow.AddHours(2); - var sut = CreateSut(context); + IdentityServerServerSideSessionStore sut = CreateSut(context); await sut.UpdateSession(updated); @@ -247,7 +250,7 @@ public async Task UpdateSession_WhenSessionExistsWithKey_ShouldUpdateStoredSessi public async Task DeleteSession_WhenKeyNullOrEmpty_ShouldThrowArgumentException(string key) { await using var context = await CreateCleanContext(TestDatabaseProviders.FirstOrDefault()); - var sut = CreateSut(context); + IdentityServerServerSideSessionStore sut = CreateSut(context); Func act = async () => await sut.DeleteSession(key); @@ -258,7 +261,7 @@ public async Task DeleteSession_WhenKeyNullOrEmpty_ShouldThrowArgumentException( public async Task DeleteSession_WhenSessionDoesntExistsWithKey_ShouldLogError(DbContextOptions options) { await using var context = await CreateCleanContext(options); - var sut = CreateSut(context); + IdentityServerServerSideSessionStore sut = CreateSut(context); await sut.DeleteSession("missing-delete-key"); @@ -270,7 +273,7 @@ public async Task DeleteSession_WhenSessionExistsWithKey_ShouldDeleteStoredSessi { await using var context = await CreateCleanContext(options); - var key = "delete-key"; + string key = "delete-key"; context.ServerSideSessions.Add(new IdentityServerServerSideSessions { Key = key, @@ -285,7 +288,7 @@ public async Task DeleteSession_WhenSessionExistsWithKey_ShouldDeleteStoredSessi }); await context.SaveChangesAsync(); - var sut = CreateSut(context); + IdentityServerServerSideSessionStore sut = CreateSut(context); await sut.DeleteSession(key); @@ -295,10 +298,51 @@ public async Task DeleteSession_WhenSessionExistsWithKey_ShouldDeleteStoredSessi stored.Should().BeNull(); } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace(DbContextOptions options) + { + List<(Func actMethod, string traceMethodName)> methods + = new() + { + (store => store.CreateSession(new SessionModel { Key = "FAKE_SESSION_KEY" }), "CreateSession"), + (store => store.GetSession("FAKE_SESSION_KEY"), "GetSession"), + (store => store.UpdateSession(new SessionModel { Key = "FAKE_SESSION_KEY" }), "UpdateSession"), + (store => store.DeleteSession("FAKE_SESSION_KEY"), "DeleteSession"), + }; + + foreach (var method in methods) + { + var 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 using PersistedGrantDbContext context = new PersistedGrantDbContext(options, StoreOptions); + + var store = CreateSut(context); + + await method.actMethod(store); + + Mock.Get(telemetry) + .Verify(t => t.Trace( + TelemetryConstants.TraceCategories.Stores, store, method.traceMethodName), Times.Once); + Mock.Get(trace).Verify(t => t.Dispose(), Times.Once); + } + + // Assert all methods covered + typeof(IdentityServerServerSideSessionStore).GetMethods() + .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)); + } private async Task CreateCleanContext(DbContextOptions options) { - var context = new PersistedGrantDbContext(options, StoreOptions); + PersistedGrantDbContext context = new PersistedGrantDbContext(options, StoreOptions); await context.Database.EnsureDeletedAsync(); await context.Database.EnsureCreatedAsync(); return context; diff --git a/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs index 62a11644f..dd2f704cf 100644 --- a/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs +++ b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.Logging; using Open.IdentityServer.Extensions; using Open.IdentityServer.Models; +using Open.IdentityServer.Services; using Open.IdentityServer.Stores.Serialization; namespace Open.IdentityServer.Stores; @@ -23,15 +24,18 @@ namespace Open.IdentityServer.Stores; /// /// /// +/// /// public class ServerSessionTicketStore( IIdentityServerServerSideSessionStore serverServerSideSessionStore, IDataProtectionProvider dataProtectionProvider, TimeProvider timeProvider, - ILogger logger): ITicketStore + ITelemetryService telemetry, + ILogger logger) : ITicketStore { - private readonly IDataProtector dataProtector = dataProtectionProvider.CreateProtector(DataProtectionConstants.ServerSideTicketStorePurpose); - + private readonly IDataProtector dataProtector = + dataProtectionProvider.CreateProtector(DataProtectionConstants.ServerSideTicketStorePurpose); + /// /// /// @@ -43,14 +47,23 @@ public class ServerSessionTicketStore( /// public async Task StoreAsync(AuthenticationTicket ticket) { - var serializedTicket = JsonSerializer.Serialize(ticket.ToSerializableObj()); - - var serverSideSession = new IdentityServerServerSideSessions + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + + string serializedTicket = JsonSerializer.Serialize(ticket.ToSerializableObj()); + + string key = Guid.NewGuid().ToString(); + string? subjectId = ticket.Principal.GetSubjectId(); + string? sessionId = ticket.Properties.GetSessionId(); + trace?.AddTag(TelemetryConstants.TagConstants.Key, key); + trace?.AddTag(TelemetryConstants.TagConstants.Subject, subjectId); + trace?.AddTag(TelemetryConstants.TagConstants.Session, sessionId); + + IdentityServerServerSideSessions serverSideSession = new IdentityServerServerSideSessions { - Key = Guid.NewGuid().ToString(), + Key = key, Scheme = ticket.AuthenticationScheme, - SubjectId = ticket.Principal.GetSubjectId(), - SessionId = ticket.Properties.GetSessionId(), + SubjectId = subjectId, + SessionId = sessionId, DisplayName = ticket.Principal.FindFirstValue(JwtClaimTypes.Name), //Make configurable? Created = ticket.Properties.IssuedUtc?.UtcDateTime ?? timeProvider.GetUtcNow().UtcDateTime, Renewed = ticket.Properties.IssuedUtc?.UtcDateTime ?? timeProvider.GetUtcNow().UtcDateTime, @@ -66,21 +79,29 @@ public async Task StoreAsync(AuthenticationTicket ticket) /// public async Task RenewAsync(string key, AuthenticationTicket ticket) { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + trace?.AddTag(TelemetryConstants.TagConstants.Key, key); + ArgumentException.ThrowIfNullOrWhiteSpace(key); - - var existingSession = await serverServerSideSessionStore.GetSession(key); + + IdentityServerServerSideSessions? existingSession = await serverServerSideSessionStore.GetSession(key); if (existingSession == null) { logger.LogError("failed renewing '{SessionKey}' session in database, session with key doesn't exists", key); return; } - - var serializedTicket = JsonSerializer.Serialize(ticket.ToSerializableObj()); + string? subjectId = ticket.Principal.GetSubjectId(); + string? sessionId = ticket.Properties.GetSessionId(); + trace?.AddTag(TelemetryConstants.TagConstants.Subject, subjectId); + trace?.AddTag(TelemetryConstants.TagConstants.Session, sessionId); + + string serializedTicket = JsonSerializer.Serialize(ticket.ToSerializableObj()); + existingSession.Scheme = ticket.AuthenticationScheme; - existingSession.SubjectId = ticket.Principal.GetSubjectId(); - existingSession.SessionId = ticket.Properties.GetSessionId(); + existingSession.SubjectId = subjectId; + existingSession.SessionId = sessionId; existingSession.DisplayName = ticket.Principal.FindFirstValue(JwtClaimTypes.Name); existingSession.Renewed = ticket.Properties.IssuedUtc?.UtcDateTime ?? timeProvider.GetUtcNow().UtcDateTime; existingSession.Expires = ticket.Properties.ExpiresUtc?.UtcDateTime; @@ -92,10 +113,13 @@ public async Task RenewAsync(string key, AuthenticationTicket ticket) /// public async Task RetrieveAsync(string key) { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + trace?.AddTag(TelemetryConstants.TagConstants.Key, key); + ArgumentException.ThrowIfNullOrWhiteSpace(key); - - var existingSession = await serverServerSideSessionStore.GetSession(key); - + + IdentityServerServerSideSessions? existingSession = await serverServerSideSessionStore.GetSession(key); + if (existingSession == null) { logger.LogInformation("session with key '{SessionKey}' doesn't exist", key); @@ -104,9 +128,10 @@ public async Task RenewAsync(string key, AuthenticationTicket ticket) try { - var unprotectedData = dataProtector.Unprotect(existingSession.Data); + string unprotectedData = dataProtector.Unprotect(existingSession.Data); - SerializedAuthenticationTicket? serializedAuthTicket = JsonSerializer.Deserialize(unprotectedData); + SerializedAuthenticationTicket? serializedAuthTicket = + JsonSerializer.Deserialize(unprotectedData); return serializedAuthTicket?.ToAuthTicket(); } @@ -120,8 +145,11 @@ public async Task RenewAsync(string key, AuthenticationTicket ticket) /// public Task RemoveAsync(string key) { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + trace?.AddTag(TelemetryConstants.TagConstants.Key, key); + ArgumentException.ThrowIfNullOrWhiteSpace(key); - + serverServerSideSessionStore.DeleteSession(key); return Task.CompletedTask; } 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 b1c5c38a1..8797ca32c 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 @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text.Json; using System.Threading.Tasks; using AwesomeAssertions; @@ -17,6 +18,7 @@ using Open.IdentityServer.EntityFramework.IntegrationTests; using Open.IdentityServer.Extensions; using Open.IdentityServer.Models; +using Open.IdentityServer.Services; using Open.IdentityServer.Stores; using Open.IdentityServer.Stores.Serialization; using Open.IdentityServer.UnitTests; @@ -26,10 +28,13 @@ namespace IdentityServer.UnitTests.Stores.Default; public class ServerSessionTicketStoreTests { - private readonly IIdentityServerServerSideSessionStore serverServerSideSessionStore = Mock.Of(); + private readonly IIdentityServerServerSideSessionStore serverServerSideSessionStore = + Mock.Of(); + private readonly IDataProtectionProvider dataProtectionProvider = Mock.Of(); private readonly MockDataProtector dataProtector = new(); private readonly FakeTimeProvider fakeTimeProvider = new(); + private readonly ITelemetryService telemetry = Mock.Of(); private readonly MockLogger logger = new(); private static readonly DateTime FakeNow = new(2026, 01, 01, 12, 0, 0, DateTimeKind.Utc); @@ -37,13 +42,14 @@ public class ServerSessionTicketStoreTests public ServerSessionTicketStoreTests() { fakeTimeProvider.SetUtcNow(FakeNow); - + Mock.Get(dataProtectionProvider) .Setup(x => x.CreateProtector(DataProtectionConstants.ServerSideTicketStorePurpose)) .Returns(dataProtector); } - - private ServerSessionTicketStore CreateSut() => new(serverServerSideSessionStore, dataProtectionProvider, fakeTimeProvider, logger); + + private ServerSessionTicketStore CreateSut() => new(serverServerSideSessionStore, dataProtectionProvider, + fakeTimeProvider, telemetry, logger); [Fact] public async Task StoreAsync_WhenOptionalValuesNotProvided_ShouldUseCorrectDefaults() @@ -58,11 +64,11 @@ public async Task StoreAsync_WhenOptionalValuesNotProvided_ShouldUseCorrectDefau 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); @@ -73,8 +79,9 @@ public async Task StoreAsync_WhenOptionalValuesNotProvided_ShouldUseCorrectDefau createdSessionModel.Created.Should().Be(FakeNow); createdSessionModel.Renewed.Should().Be(FakeNow); createdSessionModel.Expires.Should().BeNull(); - - string expectedJson = JsonSerializer.Serialize(authenticationTicket.ToSerializableObj(), ServerSessionTicketStore.JsonSettings); + + string expectedJson = JsonSerializer.Serialize(authenticationTicket.ToSerializableObj(), + ServerSessionTicketStore.JsonSettings); dataProtector.ValidateProtectedData(createdSessionModel.Data, expectedJson); } @@ -87,18 +94,19 @@ public async Task StoreAsync_WhenOptionalValuesProvided_ShouldUseThem() 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); - - AuthenticationTicket authenticationTicket = GenerateAuthenticationTicket(authScheme, subjectId, sessionId, displayName, issuedUtc, expiresUtc); + + AuthenticationTicket authenticationTicket = + 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); @@ -109,8 +117,9 @@ public async Task StoreAsync_WhenOptionalValuesProvided_ShouldUseThem() createdSessionModel.Created.Should().Be(issuedUtc); createdSessionModel.Renewed.Should().Be(issuedUtc); createdSessionModel.Expires.Should().Be(expiresUtc); - - string expectedJson = JsonSerializer.Serialize(authenticationTicket.ToSerializableObj(), ServerSessionTicketStore.JsonSettings); + + string expectedJson = JsonSerializer.Serialize(authenticationTicket.ToSerializableObj(), + ServerSessionTicketStore.JsonSettings); dataProtector.ValidateProtectedData(createdSessionModel.Data, expectedJson); } @@ -122,9 +131,9 @@ public async Task RenewAsync_WhenNoSessionWithKey_ShouldLogError() string sessionId = Guid.NewGuid().ToString(); AuthenticationTicket authenticationTicket = GenerateAuthenticationTicket(authScheme, subjectId, sessionId); - + ServerSessionTicketStore sut = CreateSut(); - + await sut.RenewAsync("non-existent-session", authenticationTicket); logger.VerifyLog(LogLevel.Error, Times.Once()); } @@ -135,13 +144,13 @@ public async Task RenewAsync_WhenOptionalValuesNotProvided_ShouldUseCorrectDefau IdentityServerServerSideSessions existingSession = new IdentityServerServerSideSessions { Key = Guid.NewGuid().ToString(), Scheme = "AuthScheme", SessionId = Guid.NewGuid().ToString(), - SubjectId = Guid.NewGuid().ToString(), DisplayName = "John Doe", + SubjectId = Guid.NewGuid().ToString(), DisplayName = "John Doe", 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), Data = "EXISTING_PROTEXTEDDAAT", }; - + const string authScheme = "FakeAuthScheme"; string subjectId = Guid.NewGuid().ToString(); string sessionId = Guid.NewGuid().ToString(); @@ -151,16 +160,16 @@ public async Task RenewAsync_WhenOptionalValuesNotProvided_ShouldUseCorrectDefau Mock.Get(serverServerSideSessionStore) .Setup(x => x.GetSession(existingSession.Key)) .ReturnsAsync(existingSession); - + IdentityServerServerSideSessions? createdSessionModel = null; Mock.Get(serverServerSideSessionStore) .Setup(x => x.UpdateSession(It.IsAny())) .Callback((session) => { createdSessionModel = session; }); - + ServerSessionTicketStore sut = CreateSut(); await sut.RenewAsync(existingSession.Key, authenticationTicket); - + createdSessionModel.Should().NotBeNull(); createdSessionModel.Key.Should().NotBeNullOrWhiteSpace(); createdSessionModel.Key.Should().Be(existingSession.Key); @@ -171,46 +180,48 @@ public async Task RenewAsync_WhenOptionalValuesNotProvided_ShouldUseCorrectDefau createdSessionModel.Created.Should().Be(existingSession.Created); createdSessionModel.Renewed.Should().Be(FakeNow); createdSessionModel.Expires.Should().BeNull(); - - string expectedJson = JsonSerializer.Serialize(authenticationTicket.ToSerializableObj(), ServerSessionTicketStore.JsonSettings); + + string expectedJson = JsonSerializer.Serialize(authenticationTicket.ToSerializableObj(), + ServerSessionTicketStore.JsonSettings); dataProtector.ValidateProtectedData(createdSessionModel.Data, expectedJson); } - + [Fact] public async Task RenewAsync_WhenOptionalValuesProvided_ShouldUseThem() { IdentityServerServerSideSessions existingSession = new IdentityServerServerSideSessions { Key = Guid.NewGuid().ToString(), Scheme = "AuthScheme", SessionId = Guid.NewGuid().ToString(), - SubjectId = Guid.NewGuid().ToString(), DisplayName = "John Doe", + SubjectId = Guid.NewGuid().ToString(), DisplayName = "John Doe", 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), Data = "EXISTING_PROTEXTEDDAAT", }; - + 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); - - AuthenticationTicket authenticationTicket = GenerateAuthenticationTicket(authScheme, subjectId, sessionId, displayName, issuedUtc, expiresUtc); + + AuthenticationTicket authenticationTicket = + GenerateAuthenticationTicket(authScheme, subjectId, sessionId, displayName, issuedUtc, expiresUtc); Mock.Get(serverServerSideSessionStore) .Setup(x => x.GetSession(existingSession.Key)) .ReturnsAsync(existingSession); - + IdentityServerServerSideSessions? createdSessionModel = null; Mock.Get(serverServerSideSessionStore) .Setup(x => x.UpdateSession(It.IsAny())) .Callback((session) => { createdSessionModel = session; }); - + ServerSessionTicketStore sut = CreateSut(); - + await sut.RenewAsync(existingSession.Key, authenticationTicket); - + createdSessionModel.Should().NotBeNull(); createdSessionModel.Key.Should().NotBeNullOrWhiteSpace(); createdSessionModel.Scheme.Should().Be(authScheme); @@ -220,8 +231,9 @@ public async Task RenewAsync_WhenOptionalValuesProvided_ShouldUseThem() createdSessionModel.Created.Should().Be(existingSession.Created); createdSessionModel.Renewed.Should().Be(issuedUtc); createdSessionModel.Expires.Should().Be(expiresUtc); - - string expectedJson = JsonSerializer.Serialize(authenticationTicket.ToSerializableObj(), ServerSessionTicketStore.JsonSettings); + + string expectedJson = JsonSerializer.Serialize(authenticationTicket.ToSerializableObj(), + ServerSessionTicketStore.JsonSettings); dataProtector.ValidateProtectedData(createdSessionModel.Data, expectedJson); } @@ -232,49 +244,55 @@ public async Task RenewAsync_WhenOptionalValuesProvided_ShouldUseThem() public async Task RetrieveAsync_WhenArgumentNullOrEmpty_ShouldThrowArgumentException(string? key) { ServerSessionTicketStore sut = CreateSut(); - + Func act = async () => await sut.RetrieveAsync(key); await act.Should().ThrowAsync(); } - + [Fact] public async Task RetrieveAsync_WhenNoSessionStoredForKey_ShouldReturnNull() { ServerSessionTicketStore sut = CreateSut(); - + AuthenticationTicket? actual = await sut.RetrieveAsync("non-existent-session"); actual.Should().BeNull(); } - + [Fact] public async Task RetrieveAsync_WhenSessionStoredForKey_ShouldReturnDeserializedAuthTicket() { IdentityServerServerSideSessions existingSession = new IdentityServerServerSideSessions { Key = Guid.NewGuid().ToString(), Scheme = "AuthScheme", SessionId = Guid.NewGuid().ToString(), - SubjectId = Guid.NewGuid().ToString(), DisplayName = "John Doe", + SubjectId = Guid.NewGuid().ToString(), DisplayName = "John Doe", 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), }; - SerializedAuthenticationTicket authenticationTicket = GenerateSerializedAuthenticationTicket(existingSession.Scheme, existingSession.SubjectId, existingSession.SessionId, existingSession.DisplayName, existingSession.Renewed, existingSession.Expires); - existingSession.Data = dataProtector.GenerateFakeProtectedData(JsonSerializer.Serialize(authenticationTicket, ServerSessionTicketStore.JsonSettings)); + SerializedAuthenticationTicket authenticationTicket = + GenerateSerializedAuthenticationTicket(existingSession.Scheme, existingSession.SubjectId, + existingSession.SessionId, existingSession.DisplayName, existingSession.Renewed, + existingSession.Expires); + existingSession.Data = + dataProtector.GenerateFakeProtectedData(JsonSerializer.Serialize(authenticationTicket, + ServerSessionTicketStore.JsonSettings)); Mock.Get(serverServerSideSessionStore) .Setup(x => x.GetSession(existingSession.Key)) .ReturnsAsync(existingSession); - + ServerSessionTicketStore sut = CreateSut(); AuthenticationTicket? actual = await sut.RetrieveAsync(existingSession.Key); actual.Should().BeOfType(); actual.AuthenticationScheme.Should().Be(existingSession.Scheme); - actual.Principal.Identity?.AuthenticationType.Should().BeEquivalentTo(authenticationTicket.User.AuthenticationType); + actual.Principal.Identity?.AuthenticationType.Should() + .BeEquivalentTo(authenticationTicket.User.AuthenticationType); actual.Properties.Items.Should().BeEquivalentTo(authenticationTicket.Items); } - + [Theory] [InlineData(null)] [InlineData("")] @@ -282,39 +300,84 @@ public async Task RetrieveAsync_WhenSessionStoredForKey_ShouldReturnDeserialized public async Task RemoveAsync_WhenArgumentNullOrEmpty_ShouldThrowArgumentException(string? key) { ServerSessionTicketStore sut = CreateSut(); - + Func act = async () => await sut.RemoveAsync(key!); await act.Should().ThrowAsync(); } - + [Fact] public async Task RemoveAsync_ShouldCallServerSideSessionStoreDelete() { string keyId = Guid.NewGuid().ToString(); - + ServerSessionTicketStore sut = CreateSut(); await sut.RemoveAsync(keyId); Mock.Get(serverServerSideSessionStore) .Verify(x => x.DeleteSession(keyId)); } - - private AuthenticationTicket GenerateAuthenticationTicket(string authScheme, string? subjectId, string? sessionId, string? displayName = null, DateTimeOffset? issuedUtc = null, DateTimeOffset? expiresUtc = null) + + [Fact] + public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace() + { + AuthenticationTicket authTicket = + GenerateAuthenticationTicket("FakeScheme", Guid.NewGuid().ToString(), Guid.NewGuid().ToString()); + + List<(Func actMethod, string traceMethodName)> methods + = + [ + (store => store.StoreAsync(authTicket), "StoreAsync"), + (store => store.RenewAsync("FAKE_KEY", authTicket), "RenewAsync"), + (store => store.RetrieveAsync("FAKE_KEY"), "RetrieveAsync"), + (store => store.RemoveAsync("FAKE_KEY"), "RemoveAsync") + ]; + + var sut = CreateSut(); + + foreach (var method in methods) + { + var 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), Times.Once); + Mock.Get(trace).Verify(t => t.Dispose(), Times.Once); + } + + // Assert all methods covered + typeof(ServerSessionTicketStore).GetMethods() + .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)); + } + + 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 SerializedAuthenticationTicket GenerateSerializedAuthenticationTicket(string authScheme, string? subjectId, string? sessionId, string? displayName = null, DateTimeOffset? issuedUtc = null, DateTimeOffset? expiresUtc = null) + + private SerializedAuthenticationTicket GenerateSerializedAuthenticationTicket(string authScheme, string? subjectId, + string? sessionId, string? displayName = null, DateTimeOffset? issuedUtc = null, + DateTimeOffset? expiresUtc = null) { List claims = []; @@ -334,17 +397,17 @@ private SerializedAuthenticationTicket GenerateSerializedAuthenticationTicket(st { 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, diff --git a/src/Storage/src/TelemetryConstants.cs b/src/Storage/src/TelemetryConstants.cs index 52a4cd41b..8d9905ec4 100644 --- a/src/Storage/src/TelemetryConstants.cs +++ b/src/Storage/src/TelemetryConstants.cs @@ -52,6 +52,7 @@ public static class TagConstants public const string Resource = "resource"; public const string Subject = "subject"; public const string Session = "session"; + public const string Key = "key"; } public static class TraceCategories From a9f72f4467371c4ad7b3140020a47ad65dda3b1f Mon Sep 17 00:00:00 2001 From: James Britton Date: Tue, 28 Jul 2026 14:25:43 +0100 Subject: [PATCH 06/59] fix: create on renew when no existing session --- .../Default/ServerSessionTicketStore.cs | 26 ++++--- .../Default/DefaultKeyMaterialServiceTests.cs | 3 +- ...uthorizationParametersMessageStoreTests.cs | 6 +- .../Default/ServerSessionTicketStoreTests.cs | 68 ++++++++++++------- 4 files changed, 63 insertions(+), 40 deletions(-) diff --git a/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs index dd2f704cf..8456d5499 100644 --- a/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs +++ b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs @@ -49,21 +49,26 @@ public async Task StoreAsync(AuthenticationTicket ticket) { using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); - string serializedTicket = JsonSerializer.Serialize(ticket.ToSerializableObj()); - string key = Guid.NewGuid().ToString(); - string? subjectId = ticket.Principal.GetSubjectId(); - string? sessionId = ticket.Properties.GetSessionId(); trace?.AddTag(TelemetryConstants.TagConstants.Key, key); - trace?.AddTag(TelemetryConstants.TagConstants.Subject, subjectId); - trace?.AddTag(TelemetryConstants.TagConstants.Session, sessionId); + + IdentityServerServerSideSessions session = await StoreNewSession(key, ticket); + trace?.AddTag(TelemetryConstants.TagConstants.Subject, session.SubjectId); + trace?.AddTag(TelemetryConstants.TagConstants.Session, session.SessionId); + + return session.Key; + } + private async Task StoreNewSession(string key, AuthenticationTicket ticket) + { + string serializedTicket = JsonSerializer.Serialize(ticket.ToSerializableObj()); + IdentityServerServerSideSessions serverSideSession = new IdentityServerServerSideSessions { Key = key, Scheme = ticket.AuthenticationScheme, - SubjectId = subjectId, - SessionId = sessionId, + SubjectId = ticket.Principal.GetSubjectId(), + SessionId = ticket.Properties.GetSessionId(), DisplayName = ticket.Principal.FindFirstValue(JwtClaimTypes.Name), //Make configurable? Created = ticket.Properties.IssuedUtc?.UtcDateTime ?? timeProvider.GetUtcNow().UtcDateTime, Renewed = ticket.Properties.IssuedUtc?.UtcDateTime ?? timeProvider.GetUtcNow().UtcDateTime, @@ -73,7 +78,7 @@ public async Task StoreAsync(AuthenticationTicket ticket) await serverServerSideSessionStore.CreateSession(serverSideSession); - return serverSideSession.Key; + return serverSideSession; } /// @@ -88,7 +93,8 @@ public async Task RenewAsync(string key, AuthenticationTicket ticket) if (existingSession == null) { - logger.LogError("failed renewing '{SessionKey}' session in database, session with key doesn't exists", key); + logger.LogInformation("failed renewing '{SessionKey}' session in database, session with key doesn't exists", key); + await StoreNewSession(key, ticket); return; } diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultKeyMaterialServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultKeyMaterialServiceTests.cs index a6d179057..5f9f8f391 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultKeyMaterialServiceTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultKeyMaterialServiceTests.cs @@ -7,13 +7,12 @@ using AwesomeAssertions; using Microsoft.IdentityModel.Tokens; using Moq; -using Open.IdentityServer; using Open.IdentityServer.Models; using Open.IdentityServer.Services; using Open.IdentityServer.Stores; using Xunit; -namespace IdentityServer.UnitTests.Services.Default; +namespace Open.IdentityServer.UnitTests.Services.Default; public class TestSigningCredentialStore(SigningCredentials signingCredentials): ISigningCredentialStore { public Task GetSigningCredentialsAsync() => Task.FromResult(signingCredentials); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/DistributedCacheAuthorizationParametersMessageStoreTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/DistributedCacheAuthorizationParametersMessageStoreTests.cs index a7f5d1055..8801f0016 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/DistributedCacheAuthorizationParametersMessageStoreTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/DistributedCacheAuthorizationParametersMessageStoreTests.cs @@ -1,16 +1,16 @@ // 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.Collections.Generic; +using System.Threading.Tasks; using AwesomeAssertions; using IdentityServer.UnitTests.Common; using Open.IdentityServer.Models; using Open.IdentityServer.Services; using Open.IdentityServer.Stores.Default; -using System.Collections.Generic; -using System.Threading.Tasks; using Xunit; -namespace IdentityServer.UnitTests.Stores.Default; +namespace Open.IdentityServer.UnitTests.Stores.Default; public class DistributedCacheAuthorizationParametersMessageStoreTests { 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 8797ca32c..ea6f9dd42 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 @@ -11,20 +11,17 @@ using AwesomeAssertions; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.DataProtection; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Time.Testing; using Moq; -using Open.IdentityServer; using Open.IdentityServer.EntityFramework.IntegrationTests; using Open.IdentityServer.Extensions; using Open.IdentityServer.Models; using Open.IdentityServer.Services; using Open.IdentityServer.Stores; using Open.IdentityServer.Stores.Serialization; -using Open.IdentityServer.UnitTests; using Xunit; -namespace IdentityServer.UnitTests.Stores.Default; +namespace Open.IdentityServer.UnitTests.Stores.Default; public class ServerSessionTicketStoreTests { @@ -123,21 +120,6 @@ public async Task StoreAsync_WhenOptionalValuesProvided_ShouldUseThem() dataProtector.ValidateProtectedData(createdSessionModel.Data, expectedJson); } - [Fact] - public async Task RenewAsync_WhenNoSessionWithKey_ShouldLogError() - { - const string authScheme = "FakeAuthScheme"; - string subjectId = Guid.NewGuid().ToString(); - string sessionId = Guid.NewGuid().ToString(); - - AuthenticationTicket authenticationTicket = GenerateAuthenticationTicket(authScheme, subjectId, sessionId); - - ServerSessionTicketStore sut = CreateSut(); - - await sut.RenewAsync("non-existent-session", authenticationTicket); - logger.VerifyLog(LogLevel.Error, Times.Once()); - } - [Fact] public async Task RenewAsync_WhenOptionalValuesNotProvided_ShouldUseCorrectDefaults() { @@ -213,24 +195,60 @@ public async Task RenewAsync_WhenOptionalValuesProvided_ShouldUseThem() .Setup(x => x.GetSession(existingSession.Key)) .ReturnsAsync(existingSession); - IdentityServerServerSideSessions? createdSessionModel = null; + IdentityServerServerSideSessions? updatedSessionModel = null; Mock.Get(serverServerSideSessionStore) .Setup(x => x.UpdateSession(It.IsAny())) - .Callback((session) => { createdSessionModel = session; }); + .Callback((session) => { updatedSessionModel = session; }); ServerSessionTicketStore sut = CreateSut(); await sut.RenewAsync(existingSession.Key, authenticationTicket); + updatedSessionModel.Should().NotBeNull(); + updatedSessionModel.Key.Should().NotBeNullOrWhiteSpace(); + updatedSessionModel.Key.Should().Be(existingSession.Key); + updatedSessionModel.Scheme.Should().Be(authScheme); + updatedSessionModel.SessionId.Should().Be(sessionId); + updatedSessionModel.SubjectId.Should().Be(subjectId); + updatedSessionModel.DisplayName.Should().Be(displayName); + updatedSessionModel.Created.Should().Be(existingSession.Created); + updatedSessionModel.Renewed.Should().Be(issuedUtc); + updatedSessionModel.Expires.Should().Be(expiresUtc); + + string expectedJson = JsonSerializer.Serialize(authenticationTicket.ToSerializableObj(), + ServerSessionTicketStore.JsonSettings); + dataProtector.ValidateProtectedData(updatedSessionModel.Data, expectedJson); + } + + [Fact] + public async Task RenewAsync_WhenNoExistingSessionWithKey_ShouldCreateNewSession() + { + const string authScheme = "FakeAuthScheme"; + string subjectId = Guid.NewGuid().ToString(); + string sessionId = Guid.NewGuid().ToString(); + + AuthenticationTicket authenticationTicket = GenerateAuthenticationTicket(authScheme, subjectId, sessionId); + + IdentityServerServerSideSessions? createdSessionModel = null; + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.CreateSession(It.IsAny())) + .Callback((session) => { createdSessionModel = session; }); + + ServerSessionTicketStore sut = CreateSut(); + + const string nonExistent = "NonExistentSession"; + await sut.RenewAsync(nonExistent, authenticationTicket); + createdSessionModel.Should().NotBeNull(); createdSessionModel.Key.Should().NotBeNullOrWhiteSpace(); + createdSessionModel.Key.Should().Be(nonExistent); createdSessionModel.Scheme.Should().Be(authScheme); createdSessionModel.SessionId.Should().Be(sessionId); createdSessionModel.SubjectId.Should().Be(subjectId); - createdSessionModel.DisplayName.Should().Be(displayName); - createdSessionModel.Created.Should().Be(existingSession.Created); - createdSessionModel.Renewed.Should().Be(issuedUtc); - createdSessionModel.Expires.Should().Be(expiresUtc); + createdSessionModel.DisplayName.Should().BeNull(); + createdSessionModel.Created.Should().Be(FakeNow); + createdSessionModel.Renewed.Should().Be(FakeNow); + createdSessionModel.Expires.Should().BeNull(); string expectedJson = JsonSerializer.Serialize(authenticationTicket.ToSerializableObj(), ServerSessionTicketStore.JsonSettings); From b671b2c092aa32718bd160781638d50ed46687cd Mon Sep 17 00:00:00 2001 From: James Britton Date: Tue, 28 Jul 2026 15:51:49 +0100 Subject: [PATCH 07/59] fix: added handling of protection envolope --- .../DataProtection/DataProtectedGrantData.cs | 4 +- .../DataProtectedSessionData.cs | 20 +++++ .../Default/ServerSessionTicketStore.cs | 67 ++++++++------ .../Default/ServerSessionTicketStoreTests.cs | 88 ++++++++++++++++--- 4 files changed, 138 insertions(+), 41 deletions(-) create mode 100644 src/Open.IdentityServer/src/DataProtection/DataProtectedSessionData.cs diff --git a/src/Open.IdentityServer/src/DataProtection/DataProtectedGrantData.cs b/src/Open.IdentityServer/src/DataProtection/DataProtectedGrantData.cs index 38b30e15d..c31a65ae9 100644 --- a/src/Open.IdentityServer/src/DataProtection/DataProtectedGrantData.cs +++ b/src/Open.IdentityServer/src/DataProtection/DataProtectedGrantData.cs @@ -24,9 +24,7 @@ public class DataProtectedGrantData public bool DataProtected { get; set; } /// - /// when has been protected via - /// IDataProtector.Protect and must be unprotected before deserialization; - /// when the payload is the raw serialized grant. + /// Payload of the grant data, either raw JSON or protected string /// public string Payload { get; set; } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/DataProtection/DataProtectedSessionData.cs b/src/Open.IdentityServer/src/DataProtection/DataProtectedSessionData.cs new file mode 100644 index 000000000..33be7ce66 --- /dev/null +++ b/src/Open.IdentityServer/src/DataProtection/DataProtectedSessionData.cs @@ -0,0 +1,20 @@ +namespace Open.IdentityServer.DataProtection; + +/// +/// Envelope used by +/// to wrap a serialized server session payload together with the metadata needed +/// to determine the version of the envelope. +/// +public class DataProtectedSessionData +{ + /// + /// Schema version of this envelope. + /// Incremented when the shape of the payload changes. + /// + public int Version { get; set; } = 1; + + /// + /// Payload of the grant data, either raw JSON or protected string + /// + public string Payload { get; set; } +} \ 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 8456d5499..baa4ecd9b 100644 --- a/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs +++ b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Logging; +using Open.IdentityServer.DataProtection; using Open.IdentityServer.Extensions; using Open.IdentityServer.Models; using Open.IdentityServer.Services; @@ -59,28 +60,6 @@ public async Task StoreAsync(AuthenticationTicket ticket) return session.Key; } - private async Task StoreNewSession(string key, AuthenticationTicket ticket) - { - string serializedTicket = JsonSerializer.Serialize(ticket.ToSerializableObj()); - - 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? - Created = ticket.Properties.IssuedUtc?.UtcDateTime ?? timeProvider.GetUtcNow().UtcDateTime, - Renewed = ticket.Properties.IssuedUtc?.UtcDateTime ?? timeProvider.GetUtcNow().UtcDateTime, - Expires = ticket.Properties.ExpiresUtc?.UtcDateTime, - Data = dataProtector.Protect(serializedTicket), - }; - - await serverServerSideSessionStore.CreateSession(serverSideSession); - - return serverSideSession; - } - /// public async Task RenewAsync(string key, AuthenticationTicket ticket) { @@ -102,8 +81,6 @@ public async Task RenewAsync(string key, AuthenticationTicket ticket) string? sessionId = ticket.Properties.GetSessionId(); trace?.AddTag(TelemetryConstants.TagConstants.Subject, subjectId); trace?.AddTag(TelemetryConstants.TagConstants.Session, sessionId); - - string serializedTicket = JsonSerializer.Serialize(ticket.ToSerializableObj()); existingSession.Scheme = ticket.AuthenticationScheme; existingSession.SubjectId = subjectId; @@ -111,7 +88,7 @@ public async Task RenewAsync(string key, AuthenticationTicket ticket) existingSession.DisplayName = ticket.Principal.FindFirstValue(JwtClaimTypes.Name); existingSession.Renewed = ticket.Properties.IssuedUtc?.UtcDateTime ?? timeProvider.GetUtcNow().UtcDateTime; existingSession.Expires = ticket.Properties.ExpiresUtc?.UtcDateTime; - existingSession.Data = dataProtector.Protect(serializedTicket); + existingSession.Data = ToProtectedDataString(ticket); await serverServerSideSessionStore.UpdateSession(existingSession); } @@ -134,7 +111,15 @@ public async Task RenewAsync(string key, AuthenticationTicket ticket) try { - string unprotectedData = dataProtector.Unprotect(existingSession.Data); + DataProtectedSessionData? dataProtectedSessionData = JsonSerializer.Deserialize(existingSession.Data, JsonSettings); + + if (dataProtectedSessionData is not { Version: 1 }) + { + logger.LogError("failed retrieving '{SessionKey}', deserialisation failed, incorrect version '{VersionOrNull}'", key, dataProtectedSessionData?.Version); + return null; + } + + string unprotectedData = dataProtector.Unprotect(dataProtectedSessionData.Payload); SerializedAuthenticationTicket? serializedAuthTicket = JsonSerializer.Deserialize(unprotectedData); @@ -159,4 +144,34 @@ public Task RemoveAsync(string key) serverServerSideSessionStore.DeleteSession(key); return Task.CompletedTask; } + + private async Task StoreNewSession(string key, AuthenticationTicket ticket) + { + 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? + Created = ticket.Properties.IssuedUtc?.UtcDateTime ?? timeProvider.GetUtcNow().UtcDateTime, + Renewed = ticket.Properties.IssuedUtc?.UtcDateTime ?? timeProvider.GetUtcNow().UtcDateTime, + Expires = ticket.Properties.ExpiresUtc?.UtcDateTime, + Data = ToProtectedDataString(ticket), + }; + + await serverServerSideSessionStore.CreateSession(serverSideSession); + + return serverSideSession; + } + + private string ToProtectedDataString(AuthenticationTicket ticket) + { + string serializedTicket = JsonSerializer.Serialize(ticket.ToSerializableObj()); + + return JsonSerializer.Serialize(new DataProtectedSessionData + { + Payload = dataProtector.Protect(serializedTicket), + }, JsonSettings); + } } \ 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 ea6f9dd42..5e9c6d08b 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 @@ -13,6 +13,7 @@ using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Time.Testing; using Moq; +using Open.IdentityServer.DataProtection; using Open.IdentityServer.EntityFramework.IntegrationTests; using Open.IdentityServer.Extensions; using Open.IdentityServer.Models; @@ -77,9 +78,15 @@ public async Task StoreAsync_WhenOptionalValuesNotProvided_ShouldUseCorrectDefau createdSessionModel.Renewed.Should().Be(FakeNow); createdSessionModel.Expires.Should().BeNull(); + 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(createdSessionModel.Data, expectedJson); + dataProtector.ValidateProtectedData(actualPayload, expectedJson); } [Fact] @@ -115,9 +122,15 @@ public async Task StoreAsync_WhenOptionalValuesProvided_ShouldUseThem() 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(createdSessionModel.Data, expectedJson); + dataProtector.ValidateProtectedData(actualPayload, expectedJson); } [Fact] @@ -163,9 +176,15 @@ public async Task RenewAsync_WhenOptionalValuesNotProvided_ShouldUseCorrectDefau createdSessionModel.Renewed.Should().Be(FakeNow); createdSessionModel.Expires.Should().BeNull(); + 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(createdSessionModel.Data, expectedJson); + dataProtector.ValidateProtectedData(actualPayload, expectedJson); } [Fact] @@ -215,9 +234,15 @@ public async Task RenewAsync_WhenOptionalValuesProvided_ShouldUseThem() updatedSessionModel.Renewed.Should().Be(issuedUtc); updatedSessionModel.Expires.Should().Be(expiresUtc); + var jsonElement = JsonElement.Parse(updatedSessionModel.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(updatedSessionModel.Data, expectedJson); + dataProtector.ValidateProtectedData(actualPayload, expectedJson); } [Fact] @@ -250,9 +275,15 @@ public async Task RenewAsync_WhenNoExistingSessionWithKey_ShouldCreateNewSession createdSessionModel.Renewed.Should().Be(FakeNow); createdSessionModel.Expires.Should().BeNull(); + 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(createdSessionModel.Data, expectedJson); + dataProtector.ValidateProtectedData(actualPayload, expectedJson); } [Theory] @@ -278,6 +309,31 @@ public async Task RetrieveAsync_WhenNoSessionStoredForKey_ShouldReturnNull() actual.Should().BeNull(); } + [Theory] + [InlineData("{invalid.json}")] + [InlineData("{\"Version\": 2, Payload: \"SOMEDATA\"}")] + public async Task RetrieveAsync_WhenSessionDataDeserialisationFails_ShouldReturnNull(string data) + { + IdentityServerServerSideSessions existingSession = new IdentityServerServerSideSessions + { + Key = Guid.NewGuid().ToString(), Scheme = "AuthScheme", SessionId = Guid.NewGuid().ToString(), + SubjectId = Guid.NewGuid().ToString(), DisplayName = "John Doe", + 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), + Data = data + }; + + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.GetSession(existingSession.Key)) + .ReturnsAsync(existingSession); + + ServerSessionTicketStore sut = CreateSut(); + AuthenticationTicket? actual = await sut.RetrieveAsync(existingSession.Key); + + actual.Should().BeNull(); + } + [Fact] public async Task RetrieveAsync_WhenSessionStoredForKey_ShouldReturnDeserializedAuthTicket() { @@ -289,13 +345,10 @@ 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(existingSession.Scheme, existingSession.SubjectId, - existingSession.SessionId, existingSession.DisplayName, existingSession.Renewed, - existingSession.Expires); - existingSession.Data = - dataProtector.GenerateFakeProtectedData(JsonSerializer.Serialize(authenticationTicket, - ServerSessionTicketStore.JsonSettings)); + SerializedAuthenticationTicket authenticationTicket = GenerateSerializedAuthenticationTicket( + existingSession.Scheme, existingSession.SubjectId, existingSession.SessionId, + existingSession.DisplayName, existingSession.Renewed, existingSession.Expires); + existingSession.Data = GenerateFakeData(authenticationTicket); Mock.Get(serverServerSideSessionStore) .Setup(x => x.GetSession(existingSession.Key)) @@ -393,6 +446,17 @@ private AuthenticationTicket GenerateAuthenticationTicket(string authScheme, str return new AuthenticationTicket(user.CreatePrincipal(), properties, authScheme); } + private string GenerateFakeData(SerializedAuthenticationTicket serializedAuthenticationTicket) + { + DataProtectedSessionData sessionData = new DataProtectedSessionData + { + Payload = dataProtector.GenerateFakeProtectedData(JsonSerializer.Serialize(serializedAuthenticationTicket, + ServerSessionTicketStore.JsonSettings)) + }; + + return JsonSerializer.Serialize(sessionData, ServerSessionTicketStore.JsonSettings); + } + private SerializedAuthenticationTicket GenerateSerializedAuthenticationTicket(string authScheme, string? subjectId, string? sessionId, string? displayName = null, DateTimeOffset? issuedUtc = null, DateTimeOffset? expiresUtc = null) From 00f7b4c6cf82da36ff8b19e277cc79f59a009613 Mon Sep 17 00:00:00 2001 From: James Britton Date: Wed, 29 Jul 2026 12:05:24 +0100 Subject: [PATCH 08/59] fix: added name and role claim type when deserializing claim principle from store --- .../src/Extensions/ClaimsPrincipleExtension.cs | 10 ++++++++-- .../Stores/Default/ServerSessionTicketStoreTests.cs | 5 +++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/Open.IdentityServer/src/Extensions/ClaimsPrincipleExtension.cs b/src/Open.IdentityServer/src/Extensions/ClaimsPrincipleExtension.cs index bae14a425..514db0d41 100644 --- a/src/Open.IdentityServer/src/Extensions/ClaimsPrincipleExtension.cs +++ b/src/Open.IdentityServer/src/Extensions/ClaimsPrincipleExtension.cs @@ -19,12 +19,18 @@ public ClaimsPrincipalLite ToSerializableObj() }; } } - + extension(ClaimsPrincipalLite claimsPrincipalLite) { public ClaimsPrincipal ToClaimsPrincipal() { - return new ClaimsPrincipal(new ClaimsIdentity(claimsPrincipalLite.Claims.ToClaims(), claimsPrincipalLite.AuthenticationType)); + ClaimsIdentity identity = new ClaimsIdentity( + claimsPrincipalLite.Claims.ToClaims(), + claimsPrincipalLite.AuthenticationType, + JwtClaimTypes.Name, + JwtClaimTypes.Role); + + return new ClaimsPrincipal(identity); } } } \ 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 5e9c6d08b..a729cb97b 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 @@ -359,8 +359,13 @@ public async Task RetrieveAsync_WhenSessionStoredForKey_ShouldReturnDeserialized actual.Should().BeOfType(); actual.AuthenticationScheme.Should().Be(existingSession.Scheme); + actual.Principal.Identity.Should().NotBeNull(); actual.Principal.Identity?.AuthenticationType.Should() .BeEquivalentTo(authenticationTicket.User.AuthenticationType); + actual.Principal.Identity?.Name.Should().BeEquivalentTo(existingSession.DisplayName); + actual.Principal.Identities.Should().Contain(x => + x.NameClaimType == JwtClaimTypes.Name && + x.RoleClaimType == JwtClaimTypes.Role); actual.Properties.Items.Should().BeEquivalentTo(authenticationTicket.Items); } From a34fe356923c557db1cae39339e2bb05922aab87 Mon Sep 17 00:00:00 2001 From: James Britton Date: Wed, 29 Jul 2026 12:20:10 +0100 Subject: [PATCH 09/59] fix: missing XML docs --- .../src/Stores/Default/ServerSessionTicketStore.cs | 13 +++++++------ .../IIdentityServerServerSideSessionStore.cs | 8 ++++---- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs index baa4ecd9b..ebb49f65b 100644 --- a/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs +++ b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs @@ -20,13 +20,14 @@ namespace Open.IdentityServer.Stores; /// -/// +/// Implementation of for storing for the server side sessions +/// implementation in Open.IdentityServer /// /// -/// -/// -/// -/// +/// data prtection provider +/// time provider +/// telemetry service +/// the logger public class ServerSessionTicketStore( IIdentityServerServerSideSessionStore serverServerSideSessionStore, IDataProtectionProvider dataProtectionProvider, @@ -38,7 +39,7 @@ public class ServerSessionTicketStore( dataProtectionProvider.CreateProtector(DataProtectionConstants.ServerSideTicketStorePurpose); /// - /// + /// to be used for storing server side sessions /// public static readonly JsonSerializerOptions JsonSettings = new() { diff --git a/src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs b/src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs index 8eb7d412b..6518e09a3 100644 --- a/src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs +++ b/src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs @@ -9,19 +9,19 @@ namespace Open.IdentityServer.Stores; /// -/// Storage and retrieval of server server side sessions +/// Storage and retrieval of server-side sessions /// public interface IIdentityServerServerSideSessionStore { /// - /// Gets server side session using unique key + /// Gets server-side session using unique key /// /// unique key of session /// found session or null if not found public Task GetSession(string key); /// - /// Stores the provides session model, must have a unique key set + /// Stores the provided session model, must have a unique key set /// /// session model to store /// void @@ -35,7 +35,7 @@ public interface IIdentityServerServerSideSessionStore public Task UpdateSession(IdentityServerServerSideSessions session); /// - /// Deletes server side session using unique key + /// Deletes server-side session using unique key /// /// unique key of session /// void From a8700ab5c4aa99a74348d4bbee77c2e3ff6ab9e4 Mon Sep 17 00:00:00 2001 From: James Britton Date: Wed, 29 Jul 2026 14:41:49 +0100 Subject: [PATCH 10/59] feat: added in memory session store to allow for integration tests and also follow same pattern as existig stores from IdS4 --- .../BuilderExtensions/Additional.cs | 3 + .../Stores/InMemory/InMemorySessionStore.cs | 54 +++++ .../Stores/InMemorySessionStoreTests.cs | 206 ++++++++++++++++++ 3 files changed, 263 insertions(+) create mode 100644 src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemorySessionStoreTests.cs diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs index 534986ffa..fa525a375 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs @@ -473,6 +473,9 @@ public static IIdentityServerBuilder AddServerSideSessions(this IIdentityServerB { builder.Services.AddSingleton, PostConfigureSessionStoreCookieAuthOptions>(); builder.Services.AddScoped(); + + // provide default in-memory implementation, not suitable for most production scenarios (following pattern implemented with existing stores) + builder.Services.TryAddSingleton(); return builder; } diff --git a/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs b/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs new file mode 100644 index 000000000..3fd803560 --- /dev/null +++ b/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs @@ -0,0 +1,54 @@ +#nullable enable + +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Open.IdentityServer.Models; + +namespace Open.IdentityServer.Stores; + +/// +/// In-memory server-side session store +/// +public class InMemorySessionStore(): IIdentityServerServerSideSessionStore +{ + private readonly ConcurrentDictionary repo = new(); + + /// + /// + /// + /// + public InMemorySessionStore(IDictionary seedData): this() + { + repo = new ConcurrentDictionary(seedData.ToList() ?? []); + } + + /// + public Task GetSession(string key) + { + repo.TryGetValue(key, out IdentityServerServerSideSessions? value); + return Task.FromResult(value); + } + + /// + public Task CreateSession(IdentityServerServerSideSessions session) + { + repo[session.Key] = session; + return Task.CompletedTask; + } + + /// + public Task UpdateSession(IdentityServerServerSideSessions session) + { + repo[session.Key] = session; + return Task.CompletedTask; + } + + /// + public Task DeleteSession(string key) + { + repo.TryRemove(key, out IdentityServerServerSideSessions? value); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemorySessionStoreTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemorySessionStoreTests.cs new file mode 100644 index 000000000..fbf6b5328 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemorySessionStoreTests.cs @@ -0,0 +1,206 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AwesomeAssertions; +using Open.IdentityServer.Models; +using Open.IdentityServer.Stores; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Stores; + +public class InMemorySessionStoreTests +{ + private InMemorySessionStore CreateSut(IDictionary? seedDictionary = null) => new(seedDictionary); + + [Fact] + public async Task GetSession_WhenSessionWithKeyIsntStored_ShouldReturnNull() + { + InMemorySessionStore sut = CreateSut(); + + IdentityServerServerSideSessions? actual = await sut.GetSession("non-session-key"); + + actual.Should().BeNull(); + } + + [Fact] + public async Task GetSession_WhenSessionWithKeyStored_ShouldReturnSession() + { + Dictionary seededSessions = new Dictionary + { + ["session-0"] = new() { Key = "session-0", DisplayName = "Session 0", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + ["session-1"] = new() { Key = "session-1", DisplayName = "Session 1", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + ["session-2"] = new() { Key = "session-2", DisplayName = "Session 2", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + ["session-3"] = new() { Key = "session-3", DisplayName = "Session 3", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + }; + + InMemorySessionStore sut = CreateSut(seededSessions); + + const string testKey = "session-2"; + IdentityServerServerSideSessions? actual = await sut.GetSession(testKey); + + actual.Should().BeEquivalentTo(seededSessions[testKey]); + } + + [Fact] + public async Task CreateSession_WhenSessionWithKeyExists_ShouldStoreSession() + { + IdentityServerServerSideSessions existingSession = new() + { + Key = "session-0", DisplayName = "Session 0", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() + }; + + IdentityServerServerSideSessions newSession = new() + { + Key = existingSession.Key, DisplayName = "Session 0 Updated", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() + }; + + InMemorySessionStore sut = CreateSut(new Dictionary + { + [existingSession.Key] = existingSession, + }); + IdentityServerServerSideSessions? preTestMethodsCall = await sut.GetSession(newSession.Key); + preTestMethodsCall.Should().BeEquivalentTo(existingSession); + + await sut.CreateSession(newSession); + IdentityServerServerSideSessions? actual = await sut.GetSession(newSession.Key); + actual.Should().BeEquivalentTo(newSession); + } + + [Fact] + public async Task CreateSession_WhenSessionWithKeyExists_ShouldNotThrow() + { + IdentityServerServerSideSessions existingSession = new() + { + Key = "session-0", DisplayName = "Session 0", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() + }; + + IdentityServerServerSideSessions newSession = new() + { + Key = existingSession.Key, DisplayName = "Session 0 Updated", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() + }; + + InMemorySessionStore sut = CreateSut(new Dictionary + { + [existingSession.Key] = existingSession, + }); + + Func act = async () => await sut.CreateSession(newSession); + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task CreateSession_WhenDoesntExist_ShouldStoreSession() + { + const string testKey = "session-0"; + IdentityServerServerSideSessions sessionToCreate = new() + { + Key = testKey, DisplayName = "Session 0", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() + }; + + InMemorySessionStore sut = CreateSut(); + IdentityServerServerSideSessions? preTestMethodsCall = await sut.GetSession(testKey); + preTestMethodsCall.Should().BeNull(); + + await sut.CreateSession(sessionToCreate); + IdentityServerServerSideSessions? actual = await sut.GetSession(testKey); + actual.Should().BeEquivalentTo(sessionToCreate); + } + + [Fact] + public async Task UpdateSession_WhenSessionDoesntExistsWithKey_ShouldStoreSession() + { + const string testKey = "session-0"; + IdentityServerServerSideSessions newSession = new() + { + Key = testKey, DisplayName = "Session 0 Updated", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() + }; + + InMemorySessionStore sut = CreateSut(); + + await sut.UpdateSession(newSession); + IdentityServerServerSideSessions? actual = await sut.GetSession(testKey); + actual.Should().BeEquivalentTo(newSession); + } + + [Fact] + public async Task UpdateSession_WhenSessionDoesntExistsWithKey_ShouldNotThrow() + { + const string testKey = "session-0"; + IdentityServerServerSideSessions newSession = new() + { + Key = testKey, DisplayName = "Session 0 Updated", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() + }; + + InMemorySessionStore sut = CreateSut(); + + Func act = async () => await sut.UpdateSession(newSession); + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task UpdateSession_WhenSessionExistsWithKey_ShouldReplaceStoredSession() + { + const string testKey = "session-0"; + IdentityServerServerSideSessions existingSession = new() + { + Key = testKey, DisplayName = "Session 0", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() + }; + + IdentityServerServerSideSessions newSession = new() + { + Key = testKey, DisplayName = "Session 0 Updated", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() + }; + + InMemorySessionStore sut = CreateSut(new Dictionary + { + [testKey] = existingSession, + }); + IdentityServerServerSideSessions? preTestMethodsCall = await sut.GetSession(testKey); + preTestMethodsCall.Should().BeEquivalentTo(existingSession); + + await sut.UpdateSession(newSession); + IdentityServerServerSideSessions? actual = await sut.GetSession(testKey); + actual.Should().BeEquivalentTo(newSession); + } + + [Fact] + public async Task DeleteSession_WhenSessionDoesntExists_ShouldNotThrow() + { + Dictionary seededSessions = new Dictionary + { + ["session-0"] = new() { Key = "session-0", DisplayName = "Session 0", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + ["session-1"] = new() { Key = "session-1", DisplayName = "Session 1", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + ["session-2"] = new() { Key = "session-2", DisplayName = "Session 2", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + ["session-3"] = new() { Key = "session-3", DisplayName = "Session 3", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + }; + InMemorySessionStore sut = CreateSut(seededSessions); + + Func act = async () => await sut.DeleteSession("non-exitsnt-session"); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task DeleteSession_WhenSessionExists_ShouldBeRemoved() + { + const string testKey = "session-2"; + Dictionary seededSessions = new Dictionary + { + ["session-0"] = new() { Key = "session-0", DisplayName = "Session 0", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + ["session-1"] = new() { Key = "session-1", DisplayName = "Session 1", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + ["session-2"] = new() { Key = "session-2", DisplayName = "Session 2", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + ["session-3"] = new() { Key = "session-3", DisplayName = "Session 3", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + }; + + InMemorySessionStore sut = CreateSut(seededSessions); + IdentityServerServerSideSessions? preTestMethodsCall = await sut.GetSession(testKey); + preTestMethodsCall.Should().BeEquivalentTo(seededSessions[testKey]); + + await sut.DeleteSession(testKey); + + IdentityServerServerSideSessions? actual = await sut.GetSession(testKey); + actual.Should().BeNull(); + } +} \ No newline at end of file From c13b357787ca44818a5264efc8a03b3ac769ad2e Mon Sep 17 00:00:00 2001 From: James Britton Date: Wed, 29 Jul 2026 14:47:00 +0100 Subject: [PATCH 11/59] feat: server side session integration tests [WIP] --- .../Common/IdentityServerPipeline.cs | 10 +++++++++- .../Endpoints/Authorize/ServerSideSessionTests.cs | 6 ++++++ 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/ServerSideSessionTests.cs diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs index f03d6b190..132b72ef7 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs @@ -75,6 +75,9 @@ public class IdentityServerPipeline public event Action OnPostConfigure = app => { }; public Func>? OnFederatedSignout; + + // Enableable Features + public bool EnableServerSideSessions { get; set; } public void Initialize(string? basePath = null, bool enableLogging = false) { @@ -131,7 +134,7 @@ public void ConfigureServices(IServiceCollection services) return handler; }); - services.AddIdentityServer(options => + var idsBuilder = services.AddIdentityServer(options => { Options = options; @@ -150,6 +153,11 @@ public void ConfigureServices(IServiceCollection services) .AddTestUsers(Users) .AddDeveloperSigningCredential(persistKey: false); + if (EnableServerSideSessions) + { + idsBuilder.AddServerSideSessions(); + } + services.AddHttpClient(IdentityServerConstants.HttpClients.BackChannelLogoutHttpClient) .AddHttpMessageHandler(() => BackChannelMessageHandler); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/ServerSideSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/ServerSideSessionTests.cs new file mode 100644 index 000000000..11f2e15fe --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/ServerSideSessionTests.cs @@ -0,0 +1,6 @@ +namespace IdentityServer.IntegrationTests.Endpoints.Authorize; + +public class ServerSideSessionTests +{ + +} \ No newline at end of file From 2426413b66821c7d342e5004e16f0eb144660842 Mon Sep 17 00:00:00 2001 From: James Britton Date: Thu, 30 Jul 2026 11:54:46 +0100 Subject: [PATCH 12/59] test: added integration tests to check for session creation and removal --- .../Common/IdentityServerPipeline.cs | 26 ++- .../Authorize/ServerSideSessionTests.cs | 6 - .../Endpoints/Authorize/SessionIdTests.cs | 1 + .../EndSessionServerSideSessionTests.cs | 118 ++++++++++++++ .../Login/LoginServerSideSessionTests.cs | 149 ++++++++++++++++++ 5 files changed, 293 insertions(+), 7 deletions(-) delete mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/ServerSideSessionTests.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionServerSideSessionTests.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs index 132b72ef7..da5691984 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs @@ -27,6 +27,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.Options; namespace IdentityServer.IntegrationTests.Common; @@ -53,6 +55,8 @@ public class IdentityServerPipeline public const string FederatedSignOutPath = "/signout-oidc"; public const string FederatedSignOutUrl = BaseUrl + FederatedSignOutPath; + public const string AuthCookieSessionIdClaimType = "Microsoft.AspNetCore.Authentication.Cookies-SessionId"; + public IdentityServerOptions? Options { get; set; } public List Clients { get; set; } = new List(); public List IdentityScopes { get; set; } = new List(); @@ -299,7 +303,7 @@ public async Task LoginAsync(ClaimsPrincipal subject) Subject = subject; await BrowserClient.GetAsync(LoginPage); - + BrowserClient.AllowAutoRedirect = old; } @@ -321,6 +325,26 @@ public Cookie GetSessionCookie() return BrowserClient!.GetCookie(BaseUrl, IdentityServerConstants.DefaultCheckSessionCookieName); } + public Cookie GetLoginCookie() + { + return BrowserClient!.GetCookie(BaseUrl, IdentityServerConstants.DefaultCookieAuthenticationScheme); + } + + public string? GetTicketStoreKeyFromAuthCookie() + { + var authCookie = GetLoginCookie(); + if (authCookie == null || string.IsNullOrWhiteSpace(authCookie.Value)) + { + return null; + } + + var optionsMonitor = Server!.Services.GetRequiredService>(); + var cookieOptions = optionsMonitor.Get(IdentityServerConstants.DefaultCookieAuthenticationScheme); + + var ticket = cookieOptions.TicketDataFormat.Unprotect(authCookie.Value); + return ticket?.Principal?.FindFirst(AuthCookieSessionIdClaimType)?.Value; + } + public string CreateAuthorizeUrl( string clientId = null!, string responseType = null!, diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/ServerSideSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/ServerSideSessionTests.cs deleted file mode 100644 index 11f2e15fe..000000000 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/ServerSideSessionTests.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace IdentityServer.IntegrationTests.Endpoints.Authorize; - -public class ServerSideSessionTests -{ - -} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/SessionIdTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/SessionIdTests.cs index 7a6ccde87..27fe22abb 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/SessionIdTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/SessionIdTests.cs @@ -80,6 +80,7 @@ public SessionIdTests() } [Fact] + [Trait("Category", Category)] public async Task session_id_should_be_reissued_if_session_cookie_absent() { await _mockPipeline.LoginAsync("bob"); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionServerSideSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionServerSideSessionTests.cs new file mode 100644 index 000000000..248ef8598 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionServerSideSessionTests.cs @@ -0,0 +1,118 @@ +#nullable enable + +using System.Collections.Generic; +using System.Net; +using System.Security.Claims; +using System.Threading.Tasks; +using AwesomeAssertions; +using IdentityServer.IntegrationTests.Common; +using Microsoft.Extensions.DependencyInjection; +using Open.IdentityServer.Models; +using Open.IdentityServer.Stores; +using Open.IdentityServer.Test; +using Xunit; + +namespace Open.IdentityServer.IntegrationTests.Endpoints.Login; + +public class EndSessionServerSideSessionTests +{ + private const string Category = "EndSessionServerSideSessionTests"; + + private IdentityServerPipeline _mockPipeline = new IdentityServerPipeline(); + private IIdentityServerServerSideSessionStore? sessionStore = null; + + public EndSessionServerSideSessionTests() + { + _mockPipeline.EnableServerSideSessions = true; + + _mockPipeline.Clients.AddRange([ + new Client + { + ClientId = "client1", + AllowedGrantTypes = GrantTypes.Implicit, + RequireConsent = false, + AllowedScopes = new List { "openid", "profile" }, + RedirectUris = new List { "https://client1/callback" }, + AllowAccessTokensViaBrowser = true + }, + new Client + { + ClientId = "client2", + AllowedGrantTypes = GrantTypes.Implicit, + RequireConsent = true, + AllowedScopes = new List { "openid", "profile", "api1", "api2" }, + RedirectUris = new List { "https://client2/callback" }, + AllowAccessTokensViaBrowser = true + } + ]); + + _mockPipeline.Users.Add(new TestUser + { + SubjectId = "bob", + Username = "bob", + Claims = + [ + new Claim("name", "Bob Loblaw"), + new Claim("email", "bob@loblaw.com"), + new Claim("role", "Attorney") + ] + }); + + _mockPipeline.IdentityScopes.AddRange([ + new IdentityResources.OpenId(), + new IdentityResources.Profile(), + new IdentityResources.Email() + ]); + _mockPipeline.ApiResources.AddRange([ + new ApiResource + { + Name = "api", + } + ]); + _mockPipeline.ApiScopes.AddRange([ + new ApiScope + { + Name = "api1" + }, + new ApiScope + { + Name = "api2" + } + ]); + + _mockPipeline.OnPreConfigure += app => + { + sessionStore = app.ApplicationServices.GetRequiredService(); + }; + + _mockPipeline.Initialize(); + } + + [Fact] + [Trait("Category", Category)] + public async Task EndSession_ShouldRemoveSession() + { + sessionStore.Should().NotBeNull(); + + await _mockPipeline.LoginAsync("bob"); + + Cookie sessionCookie = _mockPipeline.GetSessionCookie(); + + var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); + authKey.Should().NotBeNull(); + + var storedSessionPreEndSession = await sessionStore.GetSession(authKey); + storedSessionPreEndSession.Should().NotBeNull(); + storedSessionPreEndSession.SessionId.Should().Be(sessionCookie.Value); + storedSessionPreEndSession.SubjectId.Should().Be("bob"); + + await _mockPipeline.BrowserClient.GetAsync(IdentityServerPipeline.EndSessionEndpoint, + TestContext.Current.CancellationToken); + + _mockPipeline.LogoutWasCalled.Should().BeTrue(); + _mockPipeline.LogoutRequest.Should().NotBeNull(); + + var storedSessionPostEndSession = await sessionStore.GetSession(authKey); + storedSessionPostEndSession.Should().BeNull(); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs new file mode 100644 index 000000000..6367ca183 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs @@ -0,0 +1,149 @@ +#nullable enable + +using System.Collections.Generic; +using System.Net; +using System.Security.Claims; +using System.Threading.Tasks; +using AwesomeAssertions; +using IdentityServer.IntegrationTests.Common; +using Microsoft.Extensions.DependencyInjection; +using Open.IdentityServer.Models; +using Open.IdentityServer.Stores; +using Open.IdentityServer.Test; +using Xunit; + +namespace Open.IdentityServer.IntegrationTests.Endpoints.Login; + +public class LoginServerSideSessionTests +{ + private const string Category = "LoginServerSideSessionTests"; + + private IdentityServerPipeline _mockPipeline = new IdentityServerPipeline(); + private IIdentityServerServerSideSessionStore? sessionStore = null; + + public LoginServerSideSessionTests() + { + _mockPipeline.EnableServerSideSessions = true; + + _mockPipeline.Clients.AddRange([ + new Client + { + ClientId = "client1", + AllowedGrantTypes = GrantTypes.Implicit, + RequireConsent = false, + AllowedScopes = new List { "openid", "profile" }, + RedirectUris = new List { "https://client1/callback" }, + AllowAccessTokensViaBrowser = true + }, + new Client + { + ClientId = "client2", + AllowedGrantTypes = GrantTypes.Implicit, + RequireConsent = true, + AllowedScopes = new List { "openid", "profile", "api1", "api2" }, + RedirectUris = new List { "https://client2/callback" }, + AllowAccessTokensViaBrowser = true + } + ]); + + _mockPipeline.Users.Add(new TestUser + { + SubjectId = "bob", + Username = "bob", + Claims = + [ + new Claim("name", "Bob Loblaw"), + new Claim("email", "bob@loblaw.com"), + new Claim("role", "Attorney") + ] + }); + + _mockPipeline.Users.Add(new TestUser + { + SubjectId = "alice", + Username = "alice", + Claims = + [ + new Claim("name", "Alice Smith"), + new Claim("alice", "alice@smith.com"), + new Claim("role", "Attorney") + ] + }); + + _mockPipeline.IdentityScopes.AddRange([ + new IdentityResources.OpenId(), + new IdentityResources.Profile(), + new IdentityResources.Email() + ]); + _mockPipeline.ApiResources.AddRange([ + new ApiResource + { + Name = "api", + } + ]); + _mockPipeline.ApiScopes.AddRange([ + new ApiScope + { + Name = "api1" + }, + new ApiScope + { + Name = "api2" + } + ]); + + _mockPipeline.OnPreConfigure += app => + { + sessionStore = app.ApplicationServices.GetRequiredService(); + }; + + _mockPipeline.Initialize(); + } + + [Fact] + [Trait("Category", Category)] + public async Task Login_ShouldCreateSessionInServerStore() + { + sessionStore.Should().NotBeNull(); + + await _mockPipeline.LoginAsync("bob"); + + Cookie sessionCookie = _mockPipeline.GetSessionCookie(); + + var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); + authKey.Should().NotBeNull(); + + var storedSession = await sessionStore.GetSession(authKey); + storedSession.Should().NotBeNull(); + storedSession.SessionId.Should().Be(sessionCookie.Value); + storedSession.SubjectId.Should().Be("bob"); + } + + [Fact] + [Trait("Category", Category)] + public async Task Login_WhenUserChangfes_ShouldUpdateSessionInServerStore() + { + sessionStore.Should().NotBeNull(); + + await _mockPipeline.LoginAsync("bob"); + + Cookie originalSessionCookie = _mockPipeline.GetSessionCookie(); + + var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); + authKey.Should().NotBeNull(); + + var originalSession = await sessionStore.GetSession(authKey); + originalSession.Should().NotBeNull(); + originalSession.SessionId.Should().Be(originalSessionCookie.Value); + originalSession.SubjectId.Should().Be("bob"); + + await _mockPipeline.LoginAsync("alice"); + + Cookie newSessionCookie = _mockPipeline.GetSessionCookie(); + + var updatedSession = await sessionStore.GetSession(authKey); + updatedSession.Should().NotBeNull(); + updatedSession.SessionId.Should().Be(newSessionCookie.Value); + updatedSession.SubjectId.Should().Be("alice"); + } +} \ No newline at end of file From 48b1c9eebd44c30758915adb31b6dcb99628ada7 Mon Sep 17 00:00:00 2001 From: James Britton Date: Thu, 30 Jul 2026 13:37:53 +0100 Subject: [PATCH 13/59] fix: correcting warnings --- .../test/IntegrationTests/MockLogger.cs | 5 +++++ .../src/Stores/InMemory/InMemorySessionStore.cs | 3 +++ .../Common/IdentityServerPipeline.cs | 1 + .../Endpoints/CheckSession/CheckSessionTests.cs | 1 - .../EndSession/EndSessionServerSideSessionTests.cs | 5 ++++- .../Endpoints/Login/LoginServerSideSessionTests.cs | 3 +++ .../Hosting/IdentityServerMiddlewareTests.cs | 4 ++-- .../Stores/Default/DefaultAuthorizationCodeStoreTests.cs | 1 - .../Stores/Default/DefaultRefreshTokenStoreTests.cs | 1 - .../Stores/Default/DefaultUserConsentStoreTests.cs | 1 - .../Stores/Default/ServerSessionTicketStoreTests.cs | 2 +- .../Stores/InMemorySessionStoreTests.cs | 8 +++++++- .../Compatibility/IdentityServerServerSideSessions.cs | 5 +++++ 13 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/EntityFramework.Storage/test/IntegrationTests/MockLogger.cs b/src/EntityFramework.Storage/test/IntegrationTests/MockLogger.cs index a40ddf389..748c82b26 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/MockLogger.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/MockLogger.cs @@ -1,3 +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; using Microsoft.Extensions.Logging; using Moq; diff --git a/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs b/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs index 3fd803560..65b80a228 100644 --- a/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs +++ b/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs @@ -1,3 +1,6 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + #nullable enable using System.Collections.Concurrent; diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs index da5691984..9b93c2a43 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs @@ -1,4 +1,5 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. #nullable enable diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/CheckSession/CheckSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/CheckSession/CheckSessionTests.cs index 7ec2bb001..f97adef6f 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/CheckSession/CheckSessionTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/CheckSession/CheckSessionTests.cs @@ -1,7 +1,6 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. - using System.Net; using System.Threading.Tasks; using AwesomeAssertions; diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionServerSideSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionServerSideSessionTests.cs index 248ef8598..97f3ef67c 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionServerSideSessionTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionServerSideSessionTests.cs @@ -1,3 +1,6 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + #nullable enable using System.Collections.Generic; @@ -106,7 +109,7 @@ public async Task EndSession_ShouldRemoveSession() storedSessionPreEndSession.SessionId.Should().Be(sessionCookie.Value); storedSessionPreEndSession.SubjectId.Should().Be("bob"); - await _mockPipeline.BrowserClient.GetAsync(IdentityServerPipeline.EndSessionEndpoint, + await _mockPipeline.BrowserClient!.GetAsync(IdentityServerPipeline.EndSessionEndpoint, TestContext.Current.CancellationToken); _mockPipeline.LogoutWasCalled.Should().BeTrue(); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs index 6367ca183..ee1bfac15 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs @@ -1,3 +1,6 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + #nullable enable using System.Collections.Generic; diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Hosting/IdentityServerMiddlewareTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Hosting/IdentityServerMiddlewareTests.cs index fd020a359..a93d1bf1e 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Hosting/IdentityServerMiddlewareTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Hosting/IdentityServerMiddlewareTests.cs @@ -151,7 +151,7 @@ public async Task Invoke_OnUnhandledException_ShouldCountTelemetryEvent() { await InvokeSubjectMiddleware(); } - catch (Exception e) + catch (Exception) { // intentionally swallowed } @@ -175,7 +175,7 @@ public async Task Invoke_OnUnhandledException_ShouldAttachToTrace() { await InvokeSubjectMiddleware(); } - catch (Exception e) + catch (Exception) { // intentionally swallowed } diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/DefaultAuthorizationCodeStoreTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/DefaultAuthorizationCodeStoreTests.cs index eac51a98c..d81141fe3 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/DefaultAuthorizationCodeStoreTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/DefaultAuthorizationCodeStoreTests.cs @@ -123,7 +123,6 @@ public async Task StoreAuthorizationCodeAsync_WhenCalled_ShouldTelemetryTrace() .Returns(_trace); const string baseHandle = "test_base_handle"; - var expectedHandle = baseHandle + DefaultAuthorizationCodeStore.HexEncodingSuffix; Mock.Get(_handleGenerationService) .Setup(x => x.GenerateAsync()) diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/DefaultRefreshTokenStoreTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/DefaultRefreshTokenStoreTests.cs index 32e2c6fda..f6ee59b88 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/DefaultRefreshTokenStoreTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/DefaultRefreshTokenStoreTests.cs @@ -186,7 +186,6 @@ public async Task StoreRefreshTokenAsync_WhenCalled_ShouldTelemetryTrace() .Returns(_trace); const string baseHandle = "test_base_handle"; - var expectedHandle = baseHandle + DefaultRefreshTokenStore.HexEncodingSuffix; Mock.Get(_handleGenerationService) .Setup(x => x.GenerateAsync()) diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/DefaultUserConsentStoreTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/DefaultUserConsentStoreTests.cs index 034fcb8bb..85be8847c 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/DefaultUserConsentStoreTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/DefaultUserConsentStoreTests.cs @@ -130,7 +130,6 @@ public async Task StoreUserConsentAsync_WhenCalled_ShouldTelemetryTrace() .Returns(_trace); const string baseHandle = "test_base_handle"; - var expectedHandle = baseHandle + DefaultAuthorizationCodeStore.HexEncodingSuffix; Mock.Get(_handleGenerationService) .Setup(x => x.GenerateAsync()) 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 a729cb97b..0231022e6 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 @@ -294,7 +294,7 @@ public async Task RetrieveAsync_WhenArgumentNullOrEmpty_ShouldThrowArgumentExcep { ServerSessionTicketStore sut = CreateSut(); - Func act = async () => await sut.RetrieveAsync(key); + Func act = async () => await sut.RetrieveAsync(key!); await act.Should().ThrowAsync(); } 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 fbf6b5328..db18c1d70 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemorySessionStoreTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemorySessionStoreTests.cs @@ -1,3 +1,6 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + #nullable enable using System; @@ -12,7 +15,10 @@ namespace Open.IdentityServer.UnitTests.Stores; public class InMemorySessionStoreTests { - private InMemorySessionStore CreateSut(IDictionary? seedDictionary = null) => new(seedDictionary); + private InMemorySessionStore CreateSut(IDictionary? seedDictionary = null) => + seedDictionary == null ? + new InMemorySessionStore() : + new InMemorySessionStore(seedDictionary); [Fact] public async Task GetSession_WhenSessionWithKeyIsntStored_ShouldReturnNull() diff --git a/src/Storage/src/Models/Compatibility/IdentityServerServerSideSessions.cs b/src/Storage/src/Models/Compatibility/IdentityServerServerSideSessions.cs index 91b30c661..67995e71d 100644 --- a/src/Storage/src/Models/Compatibility/IdentityServerServerSideSessions.cs +++ b/src/Storage/src/Models/Compatibility/IdentityServerServerSideSessions.cs @@ -1,3 +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.Models; From e3344e557ad77e0bdedee9a32677f987361a9716 Mon Sep 17 00:00:00 2001 From: James Britton Date: Tue, 4 Aug 2026 16:21:07 +0100 Subject: [PATCH 14/59] pr: action comments from PR review, missing tests, superfluous tags on traces --- ...ServerServerSideSessionsExtensionsTests.cs | 56 ++++++++ .../Default/ServerSessionTicketStore.cs | 14 +- .../Stores/InMemory/InMemorySessionStore.cs | 9 -- .../BuilderExtensions/AdditionalTests.cs | 90 ++++++++++++ ...igureSessionStoreCookieAuthOptionsTests.cs | 109 ++++++++++++++ .../Extensions/ClaimExtensionsTests.cs | 133 ++++++++++++++++++ .../ClaimsPrincipleExtensionTests.cs | 67 +++++++++ .../Stores/InMemorySessionStoreTests.cs | 77 +++++----- 8 files changed, 496 insertions(+), 59 deletions(-) create mode 100644 src/EntityFramework.Storage/test/UnitTests/Mappers/IdentityServerServerSideSessionsExtensionsTests.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/DependencyInjection/BuilderExtensions/AdditionalTests.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/PostConfigureSessionStoreCookieAuthOptionsTests.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Extensions/ClaimExtensionsTests.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Extensions/ClaimsPrincipleExtensionTests.cs diff --git a/src/EntityFramework.Storage/test/UnitTests/Mappers/IdentityServerServerSideSessionsExtensionsTests.cs b/src/EntityFramework.Storage/test/UnitTests/Mappers/IdentityServerServerSideSessionsExtensionsTests.cs new file mode 100644 index 000000000..71e464a48 --- /dev/null +++ b/src/EntityFramework.Storage/test/UnitTests/Mappers/IdentityServerServerSideSessionsExtensionsTests.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. + +using System; +using AwesomeAssertions; +using Open.IdentityServer.EntityFramework.Entities; +using Open.IdentityServer.EntityFramework.Mappers; +using Xunit; + +namespace Open.IdentityServer.EntityFramework.UnitTests.Mappers; + +public class IdentityServerServerSideSessionsExtensionsTests +{ + [Fact] + public void EntityIdentityServerServerSideSessions_ToModel_ProducesModelWithCorrectFields() + { + IdentityServerServerSideSessions entity = new IdentityServerServerSideSessions + { + Id = 1, + Key = Guid.NewGuid().ToString(), + Scheme = "FakeScheme", + SubjectId = "fake-subject", + SessionId = "fake-session", + DisplayName = "Fake Session for User", + Created = new DateTime(2020, 01, 01, 12, 20, 0, DateTimeKind.Utc), + Renewed = new DateTime(2020, 02, 01, 12, 20, 0, DateTimeKind.Utc), + Expires = new DateTime(2020, 03, 01, 12, 20, 0, DateTimeKind.Utc), + Data = "FAKEPROTECTEDDATA" + }; + + Models.IdentityServerServerSideSessions actual = entity.ToModel(); + + entity.Should().BeEquivalentTo(actual); + } + + [Fact] + public void ModelIdentityServerServerSideSessions_ToEntity_ProducesEntityWithCorrectFields() + { + Models.IdentityServerServerSideSessions model = new Models.IdentityServerServerSideSessions + { + Key = Guid.NewGuid().ToString(), + Scheme = "FakeScheme", + SubjectId = "fake-subject", + SessionId = "fake-session", + DisplayName = "Fake Session for User", + Created = new DateTime(2020, 01, 01, 12, 20, 0, DateTimeKind.Utc), + Renewed = new DateTime(2020, 02, 01, 12, 20, 0, DateTimeKind.Utc), + Expires = new DateTime(2020, 03, 01, 12, 20, 0, DateTimeKind.Utc), + Data = "FAKEPROTECTEDDATA" + }; + + IdentityServerServerSideSessions actual = model.ToEntity(); + + model.Should().BeEquivalentTo(actual, cnf => cnf.Excluding(x => x.Id)); + } +} \ 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 ebb49f65b..e5f6f4b9f 100644 --- a/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs +++ b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs @@ -73,19 +73,14 @@ public async Task RenewAsync(string key, AuthenticationTicket ticket) if (existingSession == null) { - logger.LogInformation("failed renewing '{SessionKey}' session in database, session with key doesn't exists", key); + logger.LogWarning("failed renewing '{SessionKey}' session in database, session with key doesn't exist", key); await StoreNewSession(key, ticket); return; } - - string? subjectId = ticket.Principal.GetSubjectId(); - string? sessionId = ticket.Properties.GetSessionId(); - trace?.AddTag(TelemetryConstants.TagConstants.Subject, subjectId); - trace?.AddTag(TelemetryConstants.TagConstants.Session, sessionId); existingSession.Scheme = ticket.AuthenticationScheme; - existingSession.SubjectId = subjectId; - existingSession.SessionId = sessionId; + existingSession.SubjectId = ticket.Principal.GetSubjectId(); + existingSession.SessionId = ticket.Properties.GetSessionId(); existingSession.DisplayName = ticket.Principal.FindFirstValue(JwtClaimTypes.Name); existingSession.Renewed = ticket.Properties.IssuedUtc?.UtcDateTime ?? timeProvider.GetUtcNow().UtcDateTime; existingSession.Expires = ticket.Properties.ExpiresUtc?.UtcDateTime; @@ -106,7 +101,7 @@ public async Task RenewAsync(string key, AuthenticationTicket ticket) if (existingSession == null) { - logger.LogInformation("session with key '{SessionKey}' doesn't exist", key); + logger.LogWarning("session with key '{SessionKey}' doesn't exist", key); return null; } @@ -138,7 +133,6 @@ public async Task RenewAsync(string key, AuthenticationTicket ticket) public Task RemoveAsync(string key) { using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); - trace?.AddTag(TelemetryConstants.TagConstants.Key, key); ArgumentException.ThrowIfNullOrWhiteSpace(key); diff --git a/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs b/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs index 65b80a228..2b86b5a0c 100644 --- a/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs +++ b/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs @@ -17,15 +17,6 @@ namespace Open.IdentityServer.Stores; public class InMemorySessionStore(): IIdentityServerServerSideSessionStore { private readonly ConcurrentDictionary repo = new(); - - /// - /// - /// - /// - public InMemorySessionStore(IDictionary seedData): this() - { - repo = new ConcurrentDictionary(seedData.ToList() ?? []); - } /// public Task GetSession(string key) 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 new file mode 100644 index 000000000..a829272b2 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/DependencyInjection/BuilderExtensions/AdditionalTests.cs @@ -0,0 +1,90 @@ +// 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.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Models; +using Open.IdentityServer.Stores; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Configuration.DependencyInjection; + +public class AdditionalTests +{ + private IServiceCollection serviceCollection = new ServiceCollection(); + + [Fact] + public void AddServerSideSessions_WhenNoStoreConfigured_ShouldConfigureServerSideSessionServicesWithInMemoryStore() + { + IIdentityServerBuilder builder = new IdentityServerBuilder(serviceCollection); + + builder.AddServerSideSessions(); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(IPostConfigureOptions) && + d.ImplementationType == typeof(PostConfigureSessionStoreCookieAuthOptions) && + d.Lifetime == ServiceLifetime.Singleton); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(ITicketStore) && + d.ImplementationType == typeof(ServerSessionTicketStore) && + d.Lifetime == ServiceLifetime.Scoped); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(IIdentityServerServerSideSessionStore) && + d.ImplementationType == typeof(InMemorySessionStore) && + d.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddServerSideSessions_WhenStoreConfigured_ShouldConfigureServerSideSessionServicesWithoutInMemoryStore() + { + IIdentityServerBuilder builder = new IdentityServerBuilder(serviceCollection); + + serviceCollection.AddSingleton(); + + builder.AddServerSideSessions(); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(IPostConfigureOptions) && + d.ImplementationType == typeof(PostConfigureSessionStoreCookieAuthOptions) && + d.Lifetime == ServiceLifetime.Singleton); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(ITicketStore) && + d.ImplementationType == typeof(ServerSessionTicketStore) && + d.Lifetime == ServiceLifetime.Scoped); + + serviceCollection.Should().NotContain(d => + d.ServiceType == typeof(IIdentityServerServerSideSessionStore) && + d.ImplementationType == typeof(InMemorySessionStore) && + d.Lifetime == ServiceLifetime.Singleton); + } +} + +public class FakeIdentityServerServerSideSessionStore: IIdentityServerServerSideSessionStore +{ + public Task GetSession(string key) + { + throw new System.NotImplementedException(); + } + + public Task CreateSession(IdentityServerServerSideSessions session) + { + throw new System.NotImplementedException(); + } + + public Task UpdateSession(IdentityServerServerSideSessions session) + { + throw new System.NotImplementedException(); + } + + public Task DeleteSession(string key) + { + throw new System.NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/PostConfigureSessionStoreCookieAuthOptionsTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/PostConfigureSessionStoreCookieAuthOptionsTests.cs new file mode 100644 index 000000000..884c37b64 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/PostConfigureSessionStoreCookieAuthOptionsTests.cs @@ -0,0 +1,109 @@ +// 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 AwesomeAssertions; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.Options; +using Moq; +using Open.IdentityServer.Configuration; +using Xunit; +using AuthenticationOptions = Microsoft.AspNetCore.Authentication.AuthenticationOptions; + +namespace Open.IdentityServer.UnitTests.Configuration; + +public class PostConfigureSessionStoreCookieAuthOptionsTests +{ + private ITicketStore ticketStore = Mock.Of(); + private IdentityServerOptions idsOptions = new(); + private IOptions authOptions = + Mock.Of>(); + + private AuthenticationOptions authenticationOptions = new(); + + public PostConfigureSessionStoreCookieAuthOptionsTests() + { + Mock.Get(authOptions) + .Setup(x => x.Value) + .Returns(authenticationOptions); + } + + private PostConfigureSessionStoreCookieAuthOptions CreateSut() => new(ticketStore, idsOptions, authOptions); + + [Fact] + public void PostConfigure_WhenIdentityServerOptionsCookieAuthenticationSchemeSet_AndMatchesName_ShouldConfigureTicketStore() + { + idsOptions.Authentication.CookieAuthenticationScheme = "CookieAuthenticationScheme"; + + var sut = CreateSut(); + + var fakeOpt = new CookieAuthenticationOptions(); + sut.PostConfigure("CookieAuthenticationScheme", fakeOpt); + + fakeOpt.SessionStore.Should().NotBeNull(); + } + + [Fact] + public void PostConfigure_WhenIdentityServerOptionsCookieAuthenticationSchemeSet_AndDoesntMatchesName_ShouldNotConfigureTicketStore() + { + idsOptions.Authentication.CookieAuthenticationScheme = "CookieAuthenticationScheme"; + + var sut = CreateSut(); + + var fakeOpt = new CookieAuthenticationOptions(); + sut.PostConfigure("NonMatchingValue", fakeOpt); + + fakeOpt.SessionStore.Should().BeNull(); + } + + [Fact] + public void PostConfigure_WhenAuthenticationOptionsDefaultAuthenticateSchemeSet_AndMatchesName_ShouldConfigureTicketStore() + { + authenticationOptions.DefaultAuthenticateScheme = "DefaultAuthenticateScheme"; + + var sut = CreateSut(); + + var fakeOpt = new CookieAuthenticationOptions(); + sut.PostConfigure("DefaultAuthenticateScheme", fakeOpt); + + fakeOpt.SessionStore.Should().NotBeNull(); + } + + [Fact] + public void PostConfigure_WhenAuthenticationOptionsDefaultAuthenticateSchemeSet_AndDoesntMatchesName_ShouldNotConfigureTicketStore() + { + authenticationOptions.DefaultAuthenticateScheme = "DefaultAuthenticateScheme"; + + var sut = CreateSut(); + + var fakeOpt = new CookieAuthenticationOptions(); + sut.PostConfigure("NonMatchingValue", fakeOpt); + + fakeOpt.SessionStore.Should().BeNull(); + } + + [Fact] + public void PostConfigure_WhenAuthenticationOptionsDefaultSchemeSet_AndMatchesName_ShouldConfigureTicketStore() + { + authenticationOptions.DefaultScheme = "DefaultScheme"; + + var sut = CreateSut(); + + var fakeOpt = new CookieAuthenticationOptions(); + sut.PostConfigure("DefaultScheme", fakeOpt); + + fakeOpt.SessionStore.Should().NotBeNull(); + } + + [Fact] + public void PostConfigure_WhenAuthenticationOptionsDefaultSchemeSet_AndDoesntMatchesName_ShouldNotConfigureTicketStore() + { + authenticationOptions.DefaultScheme = "DefaultScheme"; + + var sut = CreateSut(); + + var fakeOpt = new CookieAuthenticationOptions(); + sut.PostConfigure("NonMatchingValue", fakeOpt); + + fakeOpt.SessionStore.Should().BeNull(); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Extensions/ClaimExtensionsTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Extensions/ClaimExtensionsTests.cs new file mode 100644 index 000000000..5c71be71c --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Extensions/ClaimExtensionsTests.cs @@ -0,0 +1,133 @@ +// 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.Collections.Generic; +using System.Security.Claims; +using System.Text.Json; +using AwesomeAssertions; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.Stores.Serialization; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Extensions; + +public class ClaimExtensionsTests +{ + [Fact] + public void ToClaimsDictionary_WhenDuplicates_ShouldHandleAndProduceCorrectDictionary() + { + var jsonObj = new { Data = "SOMEDATA", Veriosn = 1 }; + JsonElement jsonElement = JsonSerializer.SerializeToElement(jsonObj); + + IEnumerable testClaims = [ + new("Integer", "1", ClaimValueTypes.Integer, "FakeIssuer1"), + new("Integer", "1", ClaimValueTypes.Integer, "FakeIssuer1"), + new("Integer32", "150", ClaimValueTypes.Integer32, "FakeIssuer2"), + new("Integer32", "150", ClaimValueTypes.Integer32, "FakeIssuer2"), + new("Integer64", "522337203485477580", ClaimValueTypes.Integer64, "FakeIssuer3"), + new("Integer64", "522337203485477580", ClaimValueTypes.Integer64, "FakeIssuer3"), + new("Boolean", "true", ClaimValueTypes.Boolean, "FakeIssuer4"), + new("Boolean", "true", ClaimValueTypes.Boolean, "FakeIssuer4"), + new("Json", JsonSerializer.Serialize(jsonObj), IdentityServerConstants.ClaimValueTypes.Json, "FakeIssuer4"), + new("Json", JsonSerializer.Serialize(jsonObj), IdentityServerConstants.ClaimValueTypes.Json, "FakeIssuer4"), + ]; + + var actual = testClaims.ToClaimsDictionary(); + + IDictionary expected = new Dictionary + { + ["Integer"] = (int) 1, + ["Integer32"] = (int) 150, + ["Integer64"] = (long) 522337203485477580, + ["Boolean"] = (bool) true, + ["Json"] = (JsonElement) jsonElement, + }; + + actual.Keys.Should().HaveCount(5); + actual["Integer"].Should().BeEquivalentTo(expected["Integer"]); + actual["Integer32"].Should().BeEquivalentTo(expected["Integer32"]); + actual["Integer64"].Should().BeEquivalentTo(expected["Integer64"]); + actual["Boolean"].Should().BeEquivalentTo(expected["Boolean"]); + actual["Json"].ToString().Should().BeEquivalentTo(expected["Json"].ToString()); + } + + [Fact] + public void ToClaimsDictionary_WhenAllClaimValueTypesProvided_ShouldAllBeConvertedToCorrectObjectInDictionary() + { + var jsonObj = new { Data = "SOMEDATA", Veriosn = 1 }; + JsonElement jsonElement = JsonSerializer.SerializeToElement(jsonObj); + + IEnumerable testClaims = [ + new("Integer", "1", ClaimValueTypes.Integer, "FakeIssuer1"), + new("Integer32", "150", ClaimValueTypes.Integer32, "FakeIssuer2"), + new("Integer64", "522337203485477580", ClaimValueTypes.Integer64, "FakeIssuer3"), + new("Boolean", "true", ClaimValueTypes.Boolean, "FakeIssuer4"), + new("Json", JsonSerializer.Serialize(jsonObj), IdentityServerConstants.ClaimValueTypes.Json, "FakeIssuer4"), + ]; + + var actual = testClaims.ToClaimsDictionary(); + + IDictionary expected = new Dictionary + { + ["Integer"] = (int) 1, + ["Integer32"] = (int) 150, + ["Integer64"] = (long) 522337203485477580, + ["Boolean"] = (bool) true, + ["Json"] = (JsonElement) jsonElement, + }; + + actual["Integer"].Should().BeEquivalentTo(expected["Integer"]); + actual["Integer32"].Should().BeEquivalentTo(expected["Integer32"]); + actual["Integer64"].Should().BeEquivalentTo(expected["Integer64"]); + actual["Boolean"].Should().BeEquivalentTo(expected["Boolean"]); + actual["Json"].ToString().Should().BeEquivalentTo(expected["Json"].ToString()); + } + + [Fact] + public void ToSerializableObj_WhenEnumerableOfClaimProvided_ShouldReturnArrayOfClaimLite() + { + IEnumerable testClaims = + [ + new("Type1", "Value1", ClaimValueTypes.String, "FakeIssuer1"), + new("Type2", "Value2", ClaimValueTypes.String, "FakeIssuer2"), + new("Type3", "Value3", ClaimValueTypes.String, "FakeIssuer3"), + new("Type4", "Value4", ClaimValueTypes.String, "FakeIssuer4"), + ]; + + var actual = testClaims.ToSerializableObj(); + + ClaimLite[] expected = + [ + new() { Type = "Type1", Value = "Value1", ValueType = ClaimValueTypes.String, Issuer = "FakeIssuer1" }, + new() { Type = "Type2", Value = "Value2", ValueType = ClaimValueTypes.String, Issuer = "FakeIssuer2" }, + new() { Type = "Type3", Value = "Value3", ValueType = ClaimValueTypes.String, Issuer = "FakeIssuer3" }, + new() { Type = "Type4", Value = "Value4", ValueType = ClaimValueTypes.String, Issuer = "FakeIssuer4" }, + ]; + + actual.Should().BeEquivalentTo(expected); + } + + [Fact] + public void ToClaims_WhenArrayOfClaimLiteProvided_ShouldReturnEnumerableOfClaim() + { + ClaimLite[] testClaims = + [ + new() { Type = "Type1", Value = "Value1", ValueType = ClaimValueTypes.String, Issuer = "FakeIssuer1" }, + new() { Type = "Type2", Value = "Value2", ValueType = ClaimValueTypes.String, Issuer = "FakeIssuer2" }, + new() { Type = "Type3", Value = "Value3", ValueType = ClaimValueTypes.String, Issuer = "FakeIssuer3" }, + new() { Type = "Type4", Value = "Value4", ValueType = ClaimValueTypes.String, Issuer = "FakeIssuer4" }, + ]; + + var actual = testClaims.ToClaims(); + + IEnumerable expected = + [ + new("Type1", "Value1", ClaimValueTypes.String, "FakeIssuer1"), + new("Type2", "Value2", ClaimValueTypes.String, "FakeIssuer2"), + new("Type3", "Value3", ClaimValueTypes.String, "FakeIssuer3"), + new("Type4", "Value4", ClaimValueTypes.String, "FakeIssuer4"), + ]; + + actual.Should().BeEquivalentTo(expected); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Extensions/ClaimsPrincipleExtensionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Extensions/ClaimsPrincipleExtensionTests.cs new file mode 100644 index 000000000..88c64e1d0 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Extensions/ClaimsPrincipleExtensionTests.cs @@ -0,0 +1,67 @@ +// 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.Linq; +using System.Security.Claims; +using AwesomeAssertions; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.Stores.Serialization; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Extensions; + +public class ClaimsPrincipleExtensionTests +{ + [Fact] + public void ToSerializableObj_CalledOnClaimsPrincipal_Should() + { + const string authenticationType = "test-auth-type"; + Claim[] claims = + [ + new(JwtClaimTypes.Name, "alice"), + new(JwtClaimTypes.Role, "admin"), + new("custom", "value") + ]; + + ClaimsPrincipal principal = new(new ClaimsIdentity(claims, authenticationType)); + + ClaimsPrincipalLite actual = principal.ToSerializableObj(); + + actual.AuthenticationType.Should().Be(authenticationType); + actual.Claims.Should().BeEquivalentTo(claims.ToSerializableObj()); + } + + [Fact] + public void ToClaimsPrincipal_CalledOnClaimsPrincipalLite_Should() + { + const string authenticationType = "lite-auth-type"; + Claim[] claims = + [ + new(JwtClaimTypes.Name, "bob"), + new(JwtClaimTypes.Role, "reader"), + new("tenant", "acme") + ]; + + ClaimsPrincipalLite lite = new() + { + AuthenticationType = authenticationType, + Claims = claims.ToSerializableObj() + }; + + ClaimsPrincipal actual = lite.ToClaimsPrincipal(); + + actual.Identity.Should().NotBeNull(); + actual.Identity!.AuthenticationType.Should().Be(authenticationType); + + ClaimsIdentity identity = (ClaimsIdentity)actual.Identity; + identity.NameClaimType.Should().Be(JwtClaimTypes.Name); + identity.RoleClaimType.Should().Be(JwtClaimTypes.Role); + + actual.Claims + .Select(c => new { c.Type, c.Value, c.ValueType, c.Issuer, c.OriginalIssuer }) + .Should() + .BeEquivalentTo( + claims.Select(c => new { c.Type, c.Value, c.ValueType, c.Issuer, c.OriginalIssuer }), + options => options.WithoutStrictOrdering()); + } +} \ No newline at end of file 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 db18c1d70..ff36e5caf 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemorySessionStoreTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemorySessionStoreTests.cs @@ -15,10 +15,17 @@ namespace Open.IdentityServer.UnitTests.Stores; public class InMemorySessionStoreTests { - private InMemorySessionStore CreateSut(IDictionary? seedDictionary = null) => - seedDictionary == null ? - new InMemorySessionStore() : - new InMemorySessionStore(seedDictionary); + private InMemorySessionStore CreateSut(IEnumerable? seedSessions = null) + { + InMemorySessionStore sut = new InMemorySessionStore(); + + foreach (var seedSession in seedSessions ?? []) + { + sut.CreateSession(seedSession); + } + + return sut; + } [Fact] public async Task GetSession_WhenSessionWithKeyIsntStored_ShouldReturnNull() @@ -33,20 +40,20 @@ public async Task GetSession_WhenSessionWithKeyIsntStored_ShouldReturnNull() [Fact] public async Task GetSession_WhenSessionWithKeyStored_ShouldReturnSession() { - Dictionary seededSessions = new Dictionary - { - ["session-0"] = new() { Key = "session-0", DisplayName = "Session 0", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, - ["session-1"] = new() { Key = "session-1", DisplayName = "Session 1", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, - ["session-2"] = new() { Key = "session-2", DisplayName = "Session 2", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, - ["session-3"] = new() { Key = "session-3", DisplayName = "Session 3", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, - }; + const string testKey = "session-2"; + IdentityServerServerSideSessions testSession = new() { Key = testKey, DisplayName = "Session 2", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }; + 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 = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + testSession, + new() { Key = "session-3", DisplayName = "Session 3", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + ]; InMemorySessionStore sut = CreateSut(seededSessions); - const string testKey = "session-2"; IdentityServerServerSideSessions? actual = await sut.GetSession(testKey); - actual.Should().BeEquivalentTo(seededSessions[testKey]); + actual.Should().BeEquivalentTo(testSession); } [Fact] @@ -62,10 +69,7 @@ public async Task CreateSession_WhenSessionWithKeyExists_ShouldStoreSession() Key = existingSession.Key, DisplayName = "Session 0 Updated", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }; - InMemorySessionStore sut = CreateSut(new Dictionary - { - [existingSession.Key] = existingSession, - }); + InMemorySessionStore sut = CreateSut([existingSession]); IdentityServerServerSideSessions? preTestMethodsCall = await sut.GetSession(newSession.Key); preTestMethodsCall.Should().BeEquivalentTo(existingSession); @@ -87,10 +91,7 @@ public async Task CreateSession_WhenSessionWithKeyExists_ShouldNotThrow() Key = existingSession.Key, DisplayName = "Session 0 Updated", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }; - InMemorySessionStore sut = CreateSut(new Dictionary - { - [existingSession.Key] = existingSession, - }); + InMemorySessionStore sut = CreateSut([existingSession]); Func act = async () => await sut.CreateSession(newSession); await act.Should().NotThrowAsync(); @@ -159,10 +160,7 @@ public async Task UpdateSession_WhenSessionExistsWithKey_ShouldReplaceStoredSess Key = testKey, DisplayName = "Session 0 Updated", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }; - InMemorySessionStore sut = CreateSut(new Dictionary - { - [testKey] = existingSession, - }); + InMemorySessionStore sut = CreateSut([existingSession]); IdentityServerServerSideSessions? preTestMethodsCall = await sut.GetSession(testKey); preTestMethodsCall.Should().BeEquivalentTo(existingSession); @@ -174,13 +172,12 @@ public async Task UpdateSession_WhenSessionExistsWithKey_ShouldReplaceStoredSess [Fact] public async Task DeleteSession_WhenSessionDoesntExists_ShouldNotThrow() { - Dictionary seededSessions = new Dictionary - { - ["session-0"] = new() { Key = "session-0", DisplayName = "Session 0", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, - ["session-1"] = new() { Key = "session-1", DisplayName = "Session 1", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, - ["session-2"] = new() { Key = "session-2", DisplayName = "Session 2", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, - ["session-3"] = new() { Key = "session-3", DisplayName = "Session 3", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, - }; + 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 = 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 = Guid.NewGuid().ToString() }, + ]; InMemorySessionStore sut = CreateSut(seededSessions); Func act = async () => await sut.DeleteSession("non-exitsnt-session"); @@ -192,17 +189,17 @@ public async Task DeleteSession_WhenSessionDoesntExists_ShouldNotThrow() public async Task DeleteSession_WhenSessionExists_ShouldBeRemoved() { const string testKey = "session-2"; - Dictionary seededSessions = new Dictionary - { - ["session-0"] = new() { Key = "session-0", DisplayName = "Session 0", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, - ["session-1"] = new() { Key = "session-1", DisplayName = "Session 1", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, - ["session-2"] = new() { Key = "session-2", DisplayName = "Session 2", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, - ["session-3"] = new() { Key = "session-3", DisplayName = "Session 3", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, - }; + IdentityServerServerSideSessions testSession = new() { Key = testKey, DisplayName = "Session 2", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }; + 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 = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + testSession, + new() { Key = "session-3", DisplayName = "Session 3", SessionId = Guid.NewGuid().ToString(), SubjectId = Guid.NewGuid().ToString() }, + ]; InMemorySessionStore sut = CreateSut(seededSessions); IdentityServerServerSideSessions? preTestMethodsCall = await sut.GetSession(testKey); - preTestMethodsCall.Should().BeEquivalentTo(seededSessions[testKey]); + preTestMethodsCall.Should().BeEquivalentTo(testSession); await sut.DeleteSession(testKey); From f20173d9e1a40ac57f1c46c15e03c60d7c19c6f8 Mon Sep 17 00:00:00 2001 From: James Britton Date: Tue, 4 Aug 2026 16:21:07 +0100 Subject: [PATCH 15/59] pr: action comments from PR review, missing tests, superfluous tags on traces --- .../src/Stores/InMemory/InMemorySessionStore.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs b/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs index 2b86b5a0c..85181f8e5 100644 --- a/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs +++ b/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs @@ -4,8 +4,6 @@ #nullable enable using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using Open.IdentityServer.Models; From d5858daf413359bf4356446851802c4dfab9ec63 Mon Sep 17 00:00:00 2001 From: James Britton Date: Mon, 10 Aug 2026 09:59:43 +0100 Subject: [PATCH 16/59] feat: added settings for backchannel logout/user session coordination --- .../src/Entities/Client.cs | 2 +- .../Options/AuthenticationOptions.cs | 8 ++++- .../Options/IdentityServerOptions.cs | 32 +++++++++++-------- .../Options/ServerSideSessionsOptions.cs | 9 ++++++ src/Storage/src/Models/Client.cs | 11 +++---- 5 files changed, 40 insertions(+), 22 deletions(-) create mode 100644 src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs diff --git a/src/EntityFramework.Storage/src/Entities/Client.cs b/src/EntityFramework.Storage/src/Entities/Client.cs index a17828f50..97a0ff4d8 100644 --- a/src/EntityFramework.Storage/src/Entities/Client.cs +++ b/src/EntityFramework.Storage/src/Entities/Client.cs @@ -65,11 +65,11 @@ public class Client public string UserCodeType { get; set; } public int DeviceCodeLifetime { get; set; } = 300; public bool NonEditable { get; set; } + public bool? CoordinateLifetimeWithUserSession { get; set; } //Unused Compatibility Properties public int? CibaLifetime { get; set; } public int? PollingInterval { get; set; } - public bool? CoordinateLifetimeWithUserSession { get; set; } public string InitiateLoginUri { get; set; } public TimeSpan DPoPClockSkew { get; set; } public int DPoPValidationMode { get; set; } diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/AuthenticationOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/AuthenticationOptions.cs index 0039185c3..28d72c895 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/AuthenticationOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/AuthenticationOptions.cs @@ -1,7 +1,7 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. - using System; using Microsoft.AspNetCore.Http; @@ -60,4 +60,10 @@ public class AuthenticationOptions /// If set, will require frame-src CSP headers being emitting on the end session callback endpoint which renders iframes to clients for front-channel signout notification. /// public bool RequireCspFrameSrcForSignout { get; set; } = true; + + /// + /// If set, refresh token lifetimes will be tied to the users' session. This setting can be overridden at a client + /// level. + /// + public bool CoordinateClientLifetimesWithUserSession { get; set; } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs index 0df4a320f..98b789526 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/IdentityServerOptions.cs @@ -2,7 +2,6 @@ // Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. - namespace Open.IdentityServer.Configuration; /// @@ -54,7 +53,7 @@ public class IdentityServerOptions /// /// The endpoints configuration. /// - public EndpointsOptions Endpoints { get; set; } = new EndpointsOptions(); + public EndpointsOptions Endpoints { get; set; } = new(); /// /// Gets or sets the discovery endpoint configuration. @@ -62,7 +61,7 @@ public class IdentityServerOptions /// /// The discovery endpoint configuration. /// - public DiscoveryOptions Discovery { get; set; } = new DiscoveryOptions(); + public DiscoveryOptions Discovery { get; set; } = new(); /// /// Gets or sets the authentication options. @@ -70,7 +69,7 @@ public class IdentityServerOptions /// /// The authentication options. /// - public AuthenticationOptions Authentication { get; set; } = new AuthenticationOptions(); + public AuthenticationOptions Authentication { get; set; } = new(); /// /// Gets or sets the events options. @@ -78,7 +77,7 @@ public class IdentityServerOptions /// /// The events options. /// - public EventsOptions Events { get; set; } = new EventsOptions(); + public EventsOptions Events { get; set; } = new(); /// /// Gets or sets the max input length restrictions. @@ -86,7 +85,7 @@ public class IdentityServerOptions /// /// The length restrictions. /// - public InputLengthRestrictions InputLengthRestrictions { get; set; } = new InputLengthRestrictions(); + public InputLengthRestrictions InputLengthRestrictions { get; set; } = new(); /// /// Gets or sets the options for the user interaction. @@ -94,7 +93,7 @@ public class IdentityServerOptions /// /// The user interaction options. /// - public UserInteractionOptions UserInteraction { get; set; } = new UserInteractionOptions(); + public UserInteractionOptions UserInteraction { get; set; } = new(); /// /// Gets or sets the caching options. @@ -102,7 +101,7 @@ public class IdentityServerOptions /// /// The caching options. /// - public CachingOptions Caching { get; set; } = new CachingOptions(); + public CachingOptions Caching { get; set; } = new(); /// /// Gets or sets the cors options. @@ -110,35 +109,40 @@ public class IdentityServerOptions /// /// The cors options. /// - public CorsOptions Cors { get; set; } = new CorsOptions(); + public CorsOptions Cors { get; set; } = new(); /// /// Gets or sets the Content Security Policy options. /// - public CspOptions Csp { get; set; } = new CspOptions(); + public CspOptions Csp { get; set; } = new(); /// /// Gets or sets the validation options. /// - public ValidationOptions Validation { get; set; } = new ValidationOptions(); + public ValidationOptions Validation { get; set; } = new(); /// /// Gets or sets the device flow options. /// - public DeviceFlowOptions DeviceFlow { get; set; } = new DeviceFlowOptions(); + public DeviceFlowOptions DeviceFlow { get; set; } = new(); /// /// Gets or sets the logging options /// - public LoggingOptions Logging { get; set; } = new LoggingOptions(); + public LoggingOptions Logging { get; set; } = new(); /// /// Gets or sets the mutual TLS options. /// - public MutualTlsOptions MutualTls { get; set; } = new MutualTlsOptions(); + public MutualTlsOptions MutualTls { get; set; } = new(); /// /// Gets or sets the enable authorise response issuer param option /// public bool EnableAuthorizeResponseIssuerParam { get; set; } = false; + + /// + /// Gets or sets the server-side session options + /// + public ServerSideSessionsOptions ServerSideSessions { get; set; } = new(); } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs new file mode 100644 index 000000000..40f41c3c9 --- /dev/null +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs @@ -0,0 +1,9 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +namespace Open.IdentityServer.Configuration; + +public class ServerSideSessionsOptions +{ + public bool ExpiredSessionsTriggerBackchannelLogout { get; set; } +} \ No newline at end of file diff --git a/src/Storage/src/Models/Client.cs b/src/Storage/src/Models/Client.cs index a9fe06d1f..025e36956 100644 --- a/src/Storage/src/Models/Client.cs +++ b/src/Storage/src/Models/Client.cs @@ -431,8 +431,12 @@ IEnumerator IEnumerable.GetEnumerator() } } - //Unused Compatibility Properties + /// + /// Gets or sets the coordinate lifetime with the user session + /// + public bool? CoordinateLifetimeWithUserSession { get; set; } + //Unused Compatibility Properties /// /// Gets or sets CIBA lifetime (Unused, added for compatibility) /// @@ -443,11 +447,6 @@ IEnumerator IEnumerable.GetEnumerator() /// public int? PollingInterval { get; set; } - /// - /// Gets or sets coordinate lifetime with user session (Unused, added for compatibility) - /// - public bool? CoordinateLifetimeWithUserSession { get; set; } - /// /// Gets or sets initiate login URI (Unused, added for compatibility) /// From 64f66338eea438256eb5507bff7650dcbe6b4f0e Mon Sep 17 00:00:00 2001 From: James Britton Date: Mon, 10 Aug 2026 16:44:56 +0100 Subject: [PATCH 17/59] feat: added support for multiple client ids and types for persistent grant filters --- .../src/Stores/PersistedGrantStore.cs | 26 +++-- .../Stores/PersistedGrantStoreTests.cs | 53 ++++++++- .../InMemory/InMemoryPersistedGrantStore.cs | 14 ++- .../InMemoryPersistedGrantStoreTests.cs | 110 +++++++++++++++--- .../PersistedGrantFilterExtensions.cs | 11 +- .../src/Stores/PersistedGrantFilter.cs | 32 ++++- 6 files changed, 197 insertions(+), 49 deletions(-) diff --git a/src/EntityFramework.Storage/src/Stores/PersistedGrantStore.cs b/src/EntityFramework.Storage/src/Stores/PersistedGrantStore.cs index 53edc1201..cb84c24e4 100644 --- a/src/EntityFramework.Storage/src/Stores/PersistedGrantStore.cs +++ b/src/EntityFramework.Storage/src/Stores/PersistedGrantStore.cs @@ -172,37 +172,41 @@ private void AddFilterTags(ITrace trace, PersistedGrantFilter filter) { if (trace == null) return; - if (!String.IsNullOrWhiteSpace(filter.ClientId)) + var clientIds = filter.ClientIds.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + if (clientIds.Any()) { - trace.AddTag(TelemetryConstants.TagConstants.Client, filter.ClientId); + trace.AddTag(TelemetryConstants.TagConstants.Client, string.Join(",", filter.ClientIds)); } - if (!String.IsNullOrWhiteSpace(filter.SubjectId)) + if (!string.IsNullOrWhiteSpace(filter.SubjectId)) { trace.AddTag(TelemetryConstants.TagConstants.Subject, filter.SubjectId); } - if (!String.IsNullOrWhiteSpace(filter.Type)) + var types = filter.Types.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + if (types.Any()) { - trace.AddTag(TelemetryConstants.TagConstants.GrantType, filter.Type); + trace.AddTag(TelemetryConstants.TagConstants.GrantType, string.Join(",", filter.Types)); } } private IQueryable Filter(IQueryable query, PersistedGrantFilter filter) { - if (!String.IsNullOrWhiteSpace(filter.ClientId)) + var clientIds = filter.ClientIds.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + if (clientIds.Any()) { - query = query.Where(x => x.ClientId == filter.ClientId); + query = query.Where(x => clientIds.Contains(x.ClientId)); } - if (!String.IsNullOrWhiteSpace(filter.SessionId)) + if (!string.IsNullOrWhiteSpace(filter.SessionId)) { query = query.Where(x => x.SessionId == filter.SessionId); } - if (!String.IsNullOrWhiteSpace(filter.SubjectId)) + if (!string.IsNullOrWhiteSpace(filter.SubjectId)) { query = query.Where(x => x.SubjectId == filter.SubjectId); } - if (!String.IsNullOrWhiteSpace(filter.Type)) + var types = filter.Types.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + if (types.Any()) { - query = query.Where(x => x.Type == filter.Type); + query = query.Where(x => types.Contains(x.Type)); } return query; diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/PersistedGrantStoreTests.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/PersistedGrantStoreTests.cs index 6dd5eec44..71919bc18 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/Stores/PersistedGrantStoreTests.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/PersistedGrantStoreTests.cs @@ -187,6 +187,19 @@ public async Task GetAllAsync_Should_Filter(DbContextOptions.Create()); + + await store.RemoveAllAsync(new PersistedGrantFilter + { + ClientIds = ["c2", "c3"], + }); + context.PersistedGrants.Count().Should().Be(5); + } + + await PopulateDb(); + await using (var context = new PersistedGrantDbContext(options, StoreOptions)) + { + var store = new PersistedGrantStore(context, _telemetry, FakeLogger.Create()); + + await store.RemoveAllAsync(new PersistedGrantFilter + { + Types = ["t1", "t2"], + }); + context.PersistedGrants.Count().Should().Be(2); + } + + await PopulateDb(); + await using (var context = new PersistedGrantDbContext(options, StoreOptions)) + { + var store = new PersistedGrantStore(context, _telemetry, FakeLogger.Create()); + + await store.RemoveAllAsync(new PersistedGrantFilter + { + ClientIds = ["c1", "c3"], + Types = ["t2", "t3"], + }); + context.PersistedGrants.Count().Should().Be(7); + } + return; async Task PopulateDb() diff --git a/src/Open.IdentityServer/src/Stores/InMemory/InMemoryPersistedGrantStore.cs b/src/Open.IdentityServer/src/Stores/InMemory/InMemoryPersistedGrantStore.cs index 54da9d40b..767be3cca 100644 --- a/src/Open.IdentityServer/src/Stores/InMemory/InMemoryPersistedGrantStore.cs +++ b/src/Open.IdentityServer/src/Stores/InMemory/InMemoryPersistedGrantStore.cs @@ -77,21 +77,23 @@ private IEnumerable Filter(PersistedGrantFilter filter) from item in _repository select item.Value; - if (!String.IsNullOrWhiteSpace(filter.ClientId)) + var clientIds = filter.ClientIds.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + if (clientIds.Any()) { - query = query.Where(x => x.ClientId == filter.ClientId); + query = query.Where(x => clientIds.Contains(x.ClientId)); } - if (!String.IsNullOrWhiteSpace(filter.SessionId)) + if (!string.IsNullOrWhiteSpace(filter.SessionId)) { query = query.Where(x => x.SessionId == filter.SessionId); } - if (!String.IsNullOrWhiteSpace(filter.SubjectId)) + if (!string.IsNullOrWhiteSpace(filter.SubjectId)) { query = query.Where(x => x.SubjectId == filter.SubjectId); } - if (!String.IsNullOrWhiteSpace(filter.Type)) + var types = filter.Types.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); + if (types.Any()) { - query = query.Where(x => x.Type == filter.Type); + query = query.Where(x => types.Contains(x.Type)); } var items = query.ToArray().AsEnumerable(); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemoryPersistedGrantStoreTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemoryPersistedGrantStoreTests.cs index 9ffb4c2ff..d87074e6e 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemoryPersistedGrantStoreTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemoryPersistedGrantStoreTests.cs @@ -38,17 +38,14 @@ public async Task Store_should_persist_value() [Fact] public async Task GetAll_should_filter() { - await _subject.StoreAsync(new PersistedGrant() { Key = "key1", SubjectId = "sub1", ClientId = "client1", SessionId = "session1" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key2", SubjectId = "sub1", ClientId = "client2", SessionId = "session1" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key3", SubjectId = "sub1", ClientId = "client1", SessionId = "session2" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key4", SubjectId = "sub1", ClientId = "client3", SessionId = "session2" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key5", SubjectId = "sub1", ClientId = "client4", SessionId = "session3" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key6", SubjectId = "sub1", ClientId = "client4", SessionId = "session4" }); - - await _subject.StoreAsync(new PersistedGrant() { Key = "key7", SubjectId = "sub2", ClientId = "client4", SessionId = "session4" }); - - - + await _subject.StoreAsync(new PersistedGrant() { Key = "key1", SubjectId = "sub1", ClientId = "client1", SessionId = "session1", Type = "typeA"}); + await _subject.StoreAsync(new PersistedGrant() { Key = "key2", SubjectId = "sub1", ClientId = "client2", SessionId = "session1", Type = "typeB" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key3", SubjectId = "sub1", ClientId = "client1", SessionId = "session2", Type = "typeB" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key4", SubjectId = "sub1", ClientId = "client3", SessionId = "session2", Type = "typeA" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key5", SubjectId = "sub1", ClientId = "client4", SessionId = "session3", Type = "typeC" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key6", SubjectId = "sub1", ClientId = "client4", SessionId = "session4", Type = "typeA" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key7", SubjectId = "sub2", ClientId = "client4", SessionId = "session4", Type = "typeC" }); + (await _subject.GetAllAsync(new PersistedGrantFilter { SubjectId = "sub1" @@ -194,6 +191,25 @@ public async Task GetAll_should_filter() SessionId = "session5" })) .Select(x => x.Key).Should().BeEmpty(); + + (await _subject.GetAllAsync(new PersistedGrantFilter + { + ClientIds = ["client1", "client4"], + })) + .Select(x => x.Key).Should().BeEquivalentTo("key1", "key3", "key5", "key6", "key7"); + + (await _subject.GetAllAsync(new PersistedGrantFilter + { + Types = ["typeA", "typeC"], + })) + .Select(x => x.Key).Should().BeEquivalentTo("key1", "key4", "key5", "key6", "key7"); + + (await _subject.GetAllAsync(new PersistedGrantFilter + { + ClientIds = ["client2", "client3"], + Types = ["typeA", "typeB"], + })) + .Select(x => x.Key).Should().BeEquivalentTo("key2", "key4"); } [Fact] @@ -521,17 +537,73 @@ await _subject.RemoveAllAsync(new PersistedGrantFilter (await _subject.GetAsync("key6")).Should().NotBeNull(); (await _subject.GetAsync("key7")).Should().NotBeNull(); } + { + await Populate(); + await _subject.RemoveAllAsync(new PersistedGrantFilter + { + ClientIds = ["client1", "client2"], + }); + (await _subject.GetAsync("key1")).Should().BeNull(); + (await _subject.GetAsync("key2")).Should().BeNull(); + (await _subject.GetAsync("key3")).Should().BeNull(); + (await _subject.GetAsync("key4")).Should().NotBeNull(); + (await _subject.GetAsync("key5")).Should().NotBeNull(); + (await _subject.GetAsync("key6")).Should().NotBeNull(); + (await _subject.GetAsync("key7")).Should().NotBeNull(); + } + { + await Populate(); + await _subject.RemoveAllAsync(new PersistedGrantFilter + { + Type = "typeA", + }); + (await _subject.GetAsync("key1")).Should().BeNull(); + (await _subject.GetAsync("key2")).Should().NotBeNull(); + (await _subject.GetAsync("key3")).Should().NotBeNull(); + (await _subject.GetAsync("key4")).Should().BeNull(); + (await _subject.GetAsync("key5")).Should().NotBeNull(); + (await _subject.GetAsync("key6")).Should().NotBeNull(); + (await _subject.GetAsync("key7")).Should().BeNull(); + } + { + await Populate(); + await _subject.RemoveAllAsync(new PersistedGrantFilter + { + Types = ["typeB", "typeC"], + }); + (await _subject.GetAsync("key1")).Should().NotBeNull(); + (await _subject.GetAsync("key2")).Should().BeNull(); + (await _subject.GetAsync("key3")).Should().BeNull(); + (await _subject.GetAsync("key4")).Should().NotBeNull(); + (await _subject.GetAsync("key5")).Should().BeNull(); + (await _subject.GetAsync("key6")).Should().BeNull(); + (await _subject.GetAsync("key7")).Should().NotBeNull(); + } + { + await Populate(); + await _subject.RemoveAllAsync(new PersistedGrantFilter + { + ClientIds = ["client3", "client4"], + Types = ["typeA", "typeC"], + }); + (await _subject.GetAsync("key1")).Should().NotBeNull(); + (await _subject.GetAsync("key2")).Should().NotBeNull(); + (await _subject.GetAsync("key3")).Should().NotBeNull(); + (await _subject.GetAsync("key4")).Should().BeNull(); + (await _subject.GetAsync("key5")).Should().BeNull(); + (await _subject.GetAsync("key6")).Should().BeNull(); + (await _subject.GetAsync("key7")).Should().BeNull(); + } } private async Task Populate() { - await _subject.StoreAsync(new PersistedGrant() { Key = "key1", SubjectId = "sub1", ClientId = "client1", SessionId = "session1" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key2", SubjectId = "sub1", ClientId = "client2", SessionId = "session1" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key3", SubjectId = "sub1", ClientId = "client1", SessionId = "session2" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key4", SubjectId = "sub1", ClientId = "client3", SessionId = "session2" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key5", SubjectId = "sub1", ClientId = "client4", SessionId = "session3" }); - await _subject.StoreAsync(new PersistedGrant() { Key = "key6", SubjectId = "sub1", ClientId = "client4", SessionId = "session4" }); - - await _subject.StoreAsync(new PersistedGrant() { Key = "key7", SubjectId = "sub2", ClientId = "client4", SessionId = "session4" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key1", SubjectId = "sub1", ClientId = "client1", SessionId = "session1", Type = "typeA" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key2", SubjectId = "sub1", ClientId = "client2", SessionId = "session1", Type = "typeB" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key3", SubjectId = "sub1", ClientId = "client1", SessionId = "session2", Type = "typeB" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key4", SubjectId = "sub1", ClientId = "client3", SessionId = "session2", Type = "typeA" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key5", SubjectId = "sub1", ClientId = "client4", SessionId = "session3", Type = "typeC" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key6", SubjectId = "sub1", ClientId = "client4", SessionId = "session4", Type = "typeC" }); + await _subject.StoreAsync(new PersistedGrant() { Key = "key7", SubjectId = "sub2", ClientId = "client4", SessionId = "session4", Type = "typeA" }); } } \ No newline at end of file diff --git a/src/Storage/src/Extensions/PersistedGrantFilterExtensions.cs b/src/Storage/src/Extensions/PersistedGrantFilterExtensions.cs index 4bbcf5392..85bcf4782 100644 --- a/src/Storage/src/Extensions/PersistedGrantFilterExtensions.cs +++ b/src/Storage/src/Extensions/PersistedGrantFilterExtensions.cs @@ -4,6 +4,7 @@ using Open.IdentityServer.Stores; using System; +using System.Linq; namespace Open.IdentityServer.Extensions; @@ -18,12 +19,12 @@ public static class PersistedGrantFilterExtensions /// public static void Validate(this PersistedGrantFilter filter) { - if (filter == null) throw new ArgumentNullException(nameof(filter)); + ArgumentNullException.ThrowIfNull(filter); - if (String.IsNullOrWhiteSpace(filter.ClientId) && - String.IsNullOrWhiteSpace(filter.SessionId) && - String.IsNullOrWhiteSpace(filter.SubjectId) && - String.IsNullOrWhiteSpace(filter.Type)) + if (filter.ClientIds.Any(string.IsNullOrWhiteSpace) && + string.IsNullOrWhiteSpace(filter.SessionId) && + string.IsNullOrWhiteSpace(filter.SubjectId) && + filter.Types.Any(string.IsNullOrWhiteSpace)) { throw new ArgumentException("No filter values set.", nameof(filter)); } diff --git a/src/Storage/src/Stores/PersistedGrantFilter.cs b/src/Storage/src/Stores/PersistedGrantFilter.cs index 9bbbc51e8..c52d42e00 100644 --- a/src/Storage/src/Stores/PersistedGrantFilter.cs +++ b/src/Storage/src/Stores/PersistedGrantFilter.cs @@ -1,6 +1,8 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. +using System.Linq; + namespace Open.IdentityServer.Stores; /// @@ -19,14 +21,32 @@ public class PersistedGrantFilter /// Session id used for the grant. /// public string SessionId { get; set; } - + /// - /// Client id the grant was issued to. + /// Client id the grant was issued to. For backwards compatibility. /// - public string ClientId { get; set; } - + public string ClientId + { + init => ClientIds = [value]; + get => ClientIds.FirstOrDefault(); + } + + /// + /// Client ids the grant was issued to. Multiple elements in array interpreted as a logic 'OR' for the client id property. + /// + public string[] ClientIds { get; set; } = []; + + /// + /// The type of grant. For backwards compatibility. + /// + public string Type + { + init => Types = [value]; + get => Types.FirstOrDefault(); + } + /// - /// The type of grant. + /// The type of grant. Multiple elements in array interpreted as a logic 'OR' for the type property. /// - public string Type { get; set; } + public string[] Types { get; set; } = []; } \ No newline at end of file From 74e6799d5d1803bdae620b8e12dcbae1d96ab40c Mon Sep 17 00:00:00 2001 From: James Britton Date: Tue, 11 Aug 2026 12:56:21 +0100 Subject: [PATCH 18/59] feat: implemented user session event service for handling logout and expiry --- .../Options/ServerSideSessionsOptions.cs | 6 + .../src/IdentityServerConstants.cs | 2 + .../Contexts/UserSessionEventContext.cs | 25 ++ .../DefaultUserSessionEventsService.cs | 106 ++++++ .../src/Services/IUserSessionEventsService.cs | 29 ++ .../DefaultUserSessionEventsServiceTests.cs | 343 ++++++++++++++++++ 6 files changed, 511 insertions(+) create mode 100644 src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs create mode 100644 src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs create mode 100644 src/Open.IdentityServer/src/Services/IUserSessionEventsService.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs index 40f41c3c9..76b5f41e2 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs @@ -3,7 +3,13 @@ namespace Open.IdentityServer.Configuration; +/// +/// +/// public class ServerSideSessionsOptions { + /// + /// + /// public bool ExpiredSessionsTriggerBackchannelLogout { get; set; } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/IdentityServerConstants.cs b/src/Open.IdentityServer/src/IdentityServerConstants.cs index 7d10fdfdc..6a7dbe287 100644 --- a/src/Open.IdentityServer/src/IdentityServerConstants.cs +++ b/src/Open.IdentityServer/src/IdentityServerConstants.cs @@ -152,6 +152,8 @@ public static class PersistedGrantTypes public const string UserConsent = "user_consent"; public const string DeviceCode = "device_code"; public const string UserCode = "user_code"; + + public static readonly string[] PersistedGrantTokenTypes = [AuthorizationCode, ReferenceToken, RefreshToken]; } public static class UserCodeTypes diff --git a/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs b/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs new file mode 100644 index 000000000..23e496109 --- /dev/null +++ b/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs @@ -0,0 +1,25 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +namespace Open.IdentityServer.Models; + +/// +/// Provides the context necessary to handle user session events +/// +public class UserSessionEventContext +{ + /// + /// Subject identifier of the user of the session the event has been triggered for + /// + public string SubjectId { get; set; } + + /// + /// Session identifier of the session the event has been triggered for + /// + public string SessionId { get; set; } + + /// + /// ClientIds logged into with the user session + /// + public string[] ClientIds { get; set; } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs b/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs new file mode 100644 index 000000000..f0283a6c4 --- /dev/null +++ b/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs @@ -0,0 +1,106 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +#nullable enable + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Models; +using Open.IdentityServer.Stores; + +namespace Open.IdentityServer.Services.Default; + +/// +/// Default user session event handler for Open.IdentityServer +/// +/// client store +/// persisted grant store +/// back channel logout service +/// IdentityServer options +/// logger +public class DefaultUserSessionEventsService( + IClientStore clientStore, + IPersistedGrantStore persistedGrantStore, + IBackChannelLogoutService backChannelLogoutService, + IdentityServerOptions idsOptions, + ILogger logger) : IUserSessionEventsService +{ + /// + public async Task HandleUserSessionLogout(UserSessionEventContext sessionEventContext) + { + if (sessionEventContext.ClientIds.Length == 0) + { + return; + } + + await EndSessionForClients(sessionEventContext); + + await backChannelLogoutService.SendLogoutNotificationsAsync(new LogoutNotificationContext + { + SubjectId = sessionEventContext.SubjectId, + SessionId = sessionEventContext.SessionId, + ClientIds = sessionEventContext.ClientIds, + }); + } + + /// + public async Task HandleUserSessionExpiry(UserSessionEventContext sessionEventContext) + { + var clientToNotify = await EndSessionForClients(sessionEventContext); + + var backChannelClients = (idsOptions.ServerSideSessions.ExpiredSessionsTriggerBackchannelLogout + ? sessionEventContext.ClientIds + : clientToNotify ?? []).ToList(); + + if (backChannelClients.Count == 0) + { + return; + } + + await backChannelLogoutService.SendLogoutNotificationsAsync(new LogoutNotificationContext + { + SubjectId = sessionEventContext.SubjectId, + SessionId = sessionEventContext.SessionId, + ClientIds = backChannelClients, + }); + } + + private async Task EndSessionForClients(UserSessionEventContext sessionEventContext) + { + var clientIds = await ClientIdsToCoordinate(sessionEventContext).ToArrayAsync(); + + if (clientIds.Length == 0) + { + return null; + } + + await persistedGrantStore.RemoveAllAsync(new PersistedGrantFilter + { + SubjectId = sessionEventContext.SubjectId, + SessionId = sessionEventContext.SessionId, + ClientIds = sessionEventContext.ClientIds, + Types = IdentityServerConstants.PersistedGrantTypes.PersistedGrantTokenTypes + }); + + return clientIds; + } + + private bool ShouldCoordinate(Client client) => client.CoordinateLifetimeWithUserSession ?? + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession; + + private async IAsyncEnumerable ClientIdsToCoordinate(UserSessionEventContext sessionEventContext) + { + foreach (string clientId in sessionEventContext.ClientIds) + { + var client = await clientStore.FindClientByIdAsync(clientId); + + if (client != null && ShouldCoordinate(client)) + { + yield return client.ClientId; + } + } + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Services/IUserSessionEventsService.cs b/src/Open.IdentityServer/src/Services/IUserSessionEventsService.cs new file mode 100644 index 000000000..580fb9d2b --- /dev/null +++ b/src/Open.IdentityServer/src/Services/IUserSessionEventsService.cs @@ -0,0 +1,29 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +#nullable enable + +using System.Threading.Tasks; +using Open.IdentityServer.Models; + +namespace Open.IdentityServer.Services; + +/// +/// Service responsible handling user session events +/// +public interface IUserSessionEventsService +{ + /// + /// Triggered when session logout occurs + /// + /// context needed for handling logout event + /// + public Task HandleUserSessionLogout(UserSessionEventContext sessionEventContext); + + /// + /// Triggered when session expires + /// + /// context needed for handling logout event + /// + public Task HandleUserSessionExpiry(UserSessionEventContext sessionEventContext); +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs new file mode 100644 index 000000000..12ecead47 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs @@ -0,0 +1,343 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +#nullable enable + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Models; +using Open.IdentityServer.Services; +using Open.IdentityServer.Services.Default; +using Open.IdentityServer.Stores; +using Open.IdentityServer.UnitTests.Common; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Services.Default; + +public class DefaultUserSessionEventsServiceTests +{ + private readonly IBackChannelLogoutService backChannelLogoutService = Mock.Of(); + private readonly IClientStore clientStore = Mock.Of(); + private readonly IPersistedGrantStore persistedGrantStore = Mock.Of(); + private readonly IdentityServerOptions idsOptions = new(); + private readonly ILogger logger = TestLogger.Create(); + + private DefaultUserSessionEventsService CreateSut() => new(clientStore, persistedGrantStore, backChannelLogoutService, idsOptions, logger); + + [Fact] + public async Task HandleUserSessionLogout_WhenNoClientIdsInSession_ShouldDoNothing() + { + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = [] + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionLogout(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.IsAny()), Times.Never); + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task HandleUserSessionLogout_WhenServerDefaultCoordinateLifetimeSettingIsTrue_ShouldTriggerSessionCoordinationForClientsWithSettingEnabledAndFalse() + { + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession = true; + + List clients = [ + new() { ClientId = "fake-client-one", CoordinateLifetimeWithUserSession = null }, + new() { ClientId = "fake-client-two", CoordinateLifetimeWithUserSession = true }, + new() { ClientId = "fake-client-three", CoordinateLifetimeWithUserSession = false }, + ]; + + SetupClientStore(clients); + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = clients.Select(x => x.ClientId).ToArray() + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionLogout(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-one") && + x.ClientIds.Contains("fake-client-two") && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-one") && + x.ClientIds.Contains("fake-client-two") && + x.ClientIds.Contains("fake-client-three")))); + } + + [Fact] + public async Task HandleUserSessionLogout_WhenServerDefaultCoordinateLifetimeSettingIsFalse_ShouldTriggerSessionCoordinationForClientsWithSettingEnabledOnly() + { + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession = false; + + List clients = [ + new() { ClientId = "fake-client-one", CoordinateLifetimeWithUserSession = null }, + new() { ClientId = "fake-client-two", CoordinateLifetimeWithUserSession = true }, + new() { ClientId = "fake-client-three", CoordinateLifetimeWithUserSession = false }, + ]; + + SetupClientStore(clients); + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = clients.Select(x => x.ClientId).ToArray() + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionLogout(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-two") && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-one") && + x.ClientIds.Contains("fake-client-two") && + x.ClientIds.Contains("fake-client-three")))); + } + + [Fact] + public async Task HandleUserSessionLogout_WhenClientIdNotFound_ShouldExcludeClientIdsNotFound() + { + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession = false; + + List clients = [ + new() { ClientId = "fake-client-one", CoordinateLifetimeWithUserSession = true }, + ]; + + SetupClientStore(clients); + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = [..clients.Select(x => x.ClientId).ToList(), "fake-non-found"], + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionLogout(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-one") && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-one") && + x.ClientIds.Contains("fake-non-found")))); + } + + [Fact] + public async Task HandleUserSessionExpiry_WhenNoClientIdsInSession_ShouldDoNothing() + { + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = [], + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionExpiry(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.IsAny()), Times.Never); + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task HandleUserSessionExpiry_WhenServerDefaultCoordinateLifetimeSettingIsTrue_ShouldTriggerSessionCoordinationForClientsWithSettingEnabledAndFalse() + { + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession = true; + + List clients = [ + new() { ClientId = "fake-client-one", CoordinateLifetimeWithUserSession = null }, + new() { ClientId = "fake-client-two", CoordinateLifetimeWithUserSession = true }, + new() { ClientId = "fake-client-three", CoordinateLifetimeWithUserSession = false }, + ]; + + SetupClientStore(clients); + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = clients.Select(x => x.ClientId).ToArray(), + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionExpiry(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-one") && + x.ClientIds.Contains("fake-client-two") && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-one") && + x.ClientIds.Contains("fake-client-two")))); + } + + [Fact] + public async Task HandleUserSessionExpiry_WhenServerDefaultCoordinateLifetimeSettingIsFalse_ShouldTriggerSessionCoordinationForClientsWithSettingEnabledOnly() + { + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession = false; + + List clients = [ + new() { ClientId = "fake-client-one", CoordinateLifetimeWithUserSession = null }, + new() { ClientId = "fake-client-two", CoordinateLifetimeWithUserSession = true }, + new() { ClientId = "fake-client-three", CoordinateLifetimeWithUserSession = false }, + ]; + + SetupClientStore(clients); + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = clients.Select(x => x.ClientId).ToArray(), + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionExpiry(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-two") && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-two")))); + } + + [Fact] + public async Task HandleUserSessionExpiry_WhenExpiredSessionsTriggerBackchannelLogoutIsTrue_ShouldTriggerBackchannelLogoutOnAllClientIgnoringCoordinationSetting() + { + idsOptions.ServerSideSessions.ExpiredSessionsTriggerBackchannelLogout = true; + + List clients = [ + new() { ClientId = "fake-client-one", CoordinateLifetimeWithUserSession = null }, + new() { ClientId = "fake-client-two", CoordinateLifetimeWithUserSession = true }, + new() { ClientId = "fake-client-three", CoordinateLifetimeWithUserSession = false }, + ]; + + SetupClientStore(clients); + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = clients.Select(x => x.ClientId).ToArray(), + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionExpiry(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-two") && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => + x.SubjectId == "fakeSubject" && + x.SessionId == "fakeSession" && + x.ClientIds.Contains("fake-client-one") && + x.ClientIds.Contains("fake-client-two") && + x.ClientIds.Contains("fake-client-three")))); + } + + [Fact] + public async Task HandleUserSessionExpiry_WhenClientIdNotFound_ShouldExcludeClientIdsNotFound() + { + UserSessionEventContext userSessionCtx = new() + { + SubjectId = "fakeSubject", + SessionId = "fakeSession", + ClientIds = ["fake-non-found"], + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + await sut.HandleUserSessionExpiry(userSessionCtx); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.IsAny()), Times.Never); + + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.IsAny()), Times.Never); + } + + private void SetupClientStore(IEnumerable clients) + { + foreach (var client in clients) + { + Mock.Get(clientStore) + .Setup(x => x.FindClientByIdAsync(client.ClientId)) + .ReturnsAsync(client); + } + } +} \ No newline at end of file From 344208b06e65ab3a6ac3f770e6c1bb357d756994 Mon Sep 17 00:00:00 2001 From: James Britton Date: Tue, 11 Aug 2026 13:20:59 +0100 Subject: [PATCH 19/59] feat: added telemetry and logging with input validation --- .../Contexts/UserSessionEventContext.cs | 4 +- .../DefaultUserSessionEventsService.cs | 24 ++- .../DefaultUserSessionEventsServiceTests.cs | 148 ++++++++++++++---- 3 files changed, 142 insertions(+), 34 deletions(-) diff --git a/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs b/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs index 23e496109..b002b3cc3 100644 --- a/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs +++ b/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs @@ -17,9 +17,9 @@ public class UserSessionEventContext /// Session identifier of the session the event has been triggered for /// public string SessionId { get; set; } - + /// /// ClientIds logged into with the user session /// - public string[] ClientIds { get; set; } + public string[] ClientIds { get; set; } = []; } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs b/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs index f0283a6c4..223070175 100644 --- a/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs +++ b/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs @@ -3,6 +3,7 @@ #nullable enable +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -20,19 +21,27 @@ namespace Open.IdentityServer.Services.Default; /// persisted grant store /// back channel logout service /// IdentityServer options +/// telemetry service /// logger public class DefaultUserSessionEventsService( IClientStore clientStore, IPersistedGrantStore persistedGrantStore, IBackChannelLogoutService backChannelLogoutService, IdentityServerOptions idsOptions, + ITelemetryService telemetry, ILogger logger) : IUserSessionEventsService { /// public async Task HandleUserSessionLogout(UserSessionEventContext sessionEventContext) { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionEventContext.SessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionEventContext.SubjectId); + + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Services, this); + if (sessionEventContext.ClientIds.Length == 0) { + logger.LogInformation("no clients linked to session, nothing to be done"); return; } @@ -49,6 +58,11 @@ await backChannelLogoutService.SendLogoutNotificationsAsync(new LogoutNotificati /// public async Task HandleUserSessionExpiry(UserSessionEventContext sessionEventContext) { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionEventContext.SessionId); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionEventContext.SubjectId); + + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Services, this); + var clientToNotify = await EndSessionForClients(sessionEventContext); var backChannelClients = (idsOptions.ServerSideSessions.ExpiredSessionsTriggerBackchannelLogout @@ -57,6 +71,7 @@ public async Task HandleUserSessionExpiry(UserSessionEventContext sessionEventCo if (backChannelClients.Count == 0) { + logger.LogInformation("no backchannel clients to notify"); return; } @@ -74,6 +89,7 @@ await backChannelLogoutService.SendLogoutNotificationsAsync(new LogoutNotificati if (clientIds.Length == 0) { + logger.LogInformation("no clients to remove grants for"); return null; } @@ -88,16 +104,14 @@ await persistedGrantStore.RemoveAllAsync(new PersistedGrantFilter return clientIds; } - private bool ShouldCoordinate(Client client) => client.CoordinateLifetimeWithUserSession ?? - idsOptions.Authentication.CoordinateClientLifetimesWithUserSession; - private async IAsyncEnumerable ClientIdsToCoordinate(UserSessionEventContext sessionEventContext) { - foreach (string clientId in sessionEventContext.ClientIds) + foreach (string clientId in sessionEventContext.ClientIds ?? []) { var client = await clientStore.FindClientByIdAsync(clientId); - if (client != null && ShouldCoordinate(client)) + if (client != null && (client.CoordinateLifetimeWithUserSession ?? + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession)) { yield return client.ClientId; } diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs index 12ecead47..e6d8ec9ca 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs @@ -3,9 +3,11 @@ #nullable enable +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using AwesomeAssertions; using Microsoft.Extensions.Logging; using Moq; using Open.IdentityServer.Configuration; @@ -24,10 +26,40 @@ public class DefaultUserSessionEventsServiceTests private readonly IClientStore clientStore = Mock.Of(); private readonly IPersistedGrantStore persistedGrantStore = Mock.Of(); private readonly IdentityServerOptions idsOptions = new(); + private readonly ITelemetryService telemetry = Mock.Of(); + private readonly ITrace trace = Mock.Of(); private readonly ILogger logger = TestLogger.Create(); - private DefaultUserSessionEventsService CreateSut() => new(clientStore, persistedGrantStore, backChannelLogoutService, idsOptions, logger); + private DefaultUserSessionEventsService CreateSut() => new(clientStore, persistedGrantStore, backChannelLogoutService, idsOptions, telemetry, logger); + [Theory] + [InlineData("subjectId", null)] + [InlineData("subjectId", "")] + [InlineData("subjectId", " ")] + [InlineData(null, "subjectId")] + [InlineData("", "subjectId")] + [InlineData(" ", "subjectId")] + public async Task HandleUserSessionLogout_WhenInvalidSubjectId_ShouldThrowArgumentException(string subjectId, string sessionId) + { + UserSessionEventContext userSessionCtx = new() + { + SubjectId = subjectId, + SessionId = sessionId, + ClientIds = [] + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + Func act = async () => await sut.HandleUserSessionLogout(userSessionCtx); + + await act.Should().ThrowAsync(); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.IsAny()), Times.Never); + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.IsAny()), Times.Never); + } + [Fact] public async Task HandleUserSessionLogout_WhenNoClientIdsInSession_ShouldDoNothing() { @@ -75,11 +107,11 @@ public async Task HandleUserSessionLogout_WhenServerDefaultCoordinateLifetimeSet .Verify(x => x.RemoveAllAsync(It.Is(x => x.SubjectId == "fakeSubject" && x.SessionId == "fakeSession" && - x.ClientIds.Contains("fake-client-one") && - x.ClientIds.Contains("fake-client-two") && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.RefreshToken) && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + Enumerable.Contains(x.ClientIds, "fake-client-one") && + Enumerable.Contains(x.ClientIds, "fake-client-two") && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); Mock.Get(backChannelLogoutService) .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => @@ -117,10 +149,10 @@ public async Task HandleUserSessionLogout_WhenServerDefaultCoordinateLifetimeSet .Verify(x => x.RemoveAllAsync(It.Is(x => x.SubjectId == "fakeSubject" && x.SessionId == "fakeSession" && - x.ClientIds.Contains("fake-client-two") && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.RefreshToken) && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + Enumerable.Contains(x.ClientIds, "fake-client-two") && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); Mock.Get(backChannelLogoutService) .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => @@ -156,10 +188,10 @@ public async Task HandleUserSessionLogout_WhenClientIdNotFound_ShouldExcludeClie .Verify(x => x.RemoveAllAsync(It.Is(x => x.SubjectId == "fakeSubject" && x.SessionId == "fakeSession" && - x.ClientIds.Contains("fake-client-one") && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.RefreshToken) && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + Enumerable.Contains(x.ClientIds, "fake-client-one") && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); Mock.Get(backChannelLogoutService) .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => @@ -168,6 +200,34 @@ public async Task HandleUserSessionLogout_WhenClientIdNotFound_ShouldExcludeClie x.ClientIds.Contains("fake-client-one") && x.ClientIds.Contains("fake-non-found")))); } + + [Theory] + [InlineData("subjectId", null)] + [InlineData("subjectId", "")] + [InlineData("subjectId", " ")] + [InlineData(null, "subjectId")] + [InlineData("", "subjectId")] + [InlineData(" ", "subjectId")] + public async Task HandleUserSessionExpiry_WhenInvalidSubjectId_ShouldThrowArgumentException(string subjectId, string sessionId) + { + UserSessionEventContext userSessionCtx = new() + { + SubjectId = subjectId, + SessionId = sessionId, + ClientIds = [] + }; + + DefaultUserSessionEventsService sut = CreateSut(); + + Func act = async () => await sut.HandleUserSessionExpiry(userSessionCtx); + + await act.Should().ThrowAsync(); + + Mock.Get(persistedGrantStore) + .Verify(x => x.RemoveAllAsync(It.IsAny()), Times.Never); + Mock.Get(backChannelLogoutService) + .Verify(x => x.SendLogoutNotificationsAsync(It.IsAny()), Times.Never); + } [Fact] public async Task HandleUserSessionExpiry_WhenNoClientIdsInSession_ShouldDoNothing() @@ -216,11 +276,11 @@ public async Task HandleUserSessionExpiry_WhenServerDefaultCoordinateLifetimeSet .Verify(x => x.RemoveAllAsync(It.Is(x => x.SubjectId == "fakeSubject" && x.SessionId == "fakeSession" && - x.ClientIds.Contains("fake-client-one") && - x.ClientIds.Contains("fake-client-two") && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.RefreshToken) && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + Enumerable.Contains(x.ClientIds, "fake-client-one") && + Enumerable.Contains(x.ClientIds, "fake-client-two") && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); Mock.Get(backChannelLogoutService) .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => @@ -257,10 +317,10 @@ public async Task HandleUserSessionExpiry_WhenServerDefaultCoordinateLifetimeSet .Verify(x => x.RemoveAllAsync(It.Is(x => x.SubjectId == "fakeSubject" && x.SessionId == "fakeSession" && - x.ClientIds.Contains("fake-client-two") && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.RefreshToken) && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + Enumerable.Contains(x.ClientIds, "fake-client-two") && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); Mock.Get(backChannelLogoutService) .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => @@ -296,10 +356,10 @@ public async Task HandleUserSessionExpiry_WhenExpiredSessionsTriggerBackchannelL .Verify(x => x.RemoveAllAsync(It.Is(x => x.SubjectId == "fakeSubject" && x.SessionId == "fakeSession" && - x.ClientIds.Contains("fake-client-two") && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.RefreshToken) && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && - x.Types.Contains(IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + Enumerable.Contains(x.ClientIds, "fake-client-two") && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); Mock.Get(backChannelLogoutService) .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => @@ -330,6 +390,40 @@ public async Task HandleUserSessionExpiry_WhenClientIdNotFound_ShouldExcludeClie Mock.Get(backChannelLogoutService) .Verify(x => x.SendLogoutNotificationsAsync(It.IsAny()), Times.Never); } + + [Fact] + public async Task HandleUserSessionLogout_WhenCalled_ShouldInitiateTelemetryTrace() + { + Mock.Get(telemetry) + .Setup(t => t.Trace(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(trace); + + DefaultUserSessionEventsService sut = CreateSut(); + await sut.HandleUserSessionLogout(new UserSessionEventContext { SessionId = "session", SubjectId = "subject" }); + + Mock.Get(telemetry) + .Verify(t => t.Trace( + TelemetryConstants.TraceCategories.Services, sut, "HandleUserSessionLogout")); + Mock.Get(trace) + .Verify(t => t.Dispose(), Times.Once); + } + + [Fact] + public async Task HandleUserSessionExpiry_WhenCalled_ShouldInitiateTelemetryTrace() + { + Mock.Get(telemetry) + .Setup(t => t.Trace(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(trace); + + DefaultUserSessionEventsService sut = CreateSut(); + await sut.HandleUserSessionExpiry(new UserSessionEventContext { SessionId = "session", SubjectId = "subject" }); + + Mock.Get(telemetry) + .Verify(t => t.Trace( + TelemetryConstants.TraceCategories.Services, sut, "HandleUserSessionExpiry")); + Mock.Get(trace) + .Verify(t => t.Dispose(), Times.Once); + } private void SetupClientStore(IEnumerable clients) { From d847b5c3c7217c737093f492c9e9a05afa8c94ff Mon Sep 17 00:00:00 2001 From: James Britton Date: Tue, 11 Aug 2026 14:53:37 +0100 Subject: [PATCH 20/59] feat: link up lohout event and handler created in user session events service --- .../BuilderExtensions/Additional.cs | 2 + .../src/Hosting/IdentityServerMiddleware.cs | 21 ++++--- .../Hosting/IdentityServerMiddlewareTests.cs | 57 ++++++++++++++++++- 3 files changed, 72 insertions(+), 8 deletions(-) diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs index fa525a375..96bd21406 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs @@ -16,6 +16,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Open.IdentityServer.Models; +using Open.IdentityServer.Services.Default; namespace Microsoft.Extensions.DependencyInjection; @@ -473,6 +474,7 @@ public static IIdentityServerBuilder AddServerSideSessions(this IIdentityServerB { builder.Services.AddSingleton, PostConfigureSessionStoreCookieAuthOptions>(); builder.Services.AddScoped(); + builder.Services.AddScoped(); // provide default in-memory implementation, not suitable for most production scenarios (following pattern implemented with existing stores) builder.Services.TryAddSingleton(); diff --git a/src/Open.IdentityServer/src/Hosting/IdentityServerMiddleware.cs b/src/Open.IdentityServer/src/Hosting/IdentityServerMiddleware.cs index 31a180a58..895364ed9 100644 --- a/src/Open.IdentityServer/src/Hosting/IdentityServerMiddleware.cs +++ b/src/Open.IdentityServer/src/Hosting/IdentityServerMiddleware.cs @@ -9,7 +9,9 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using System; +using System.Linq; using System.Threading.Tasks; +using Open.IdentityServer.Models; namespace Open.IdentityServer.Hosting; @@ -40,6 +42,7 @@ public IdentityServerMiddleware(RequestDelegate next, ILoggerThe user session. /// The event service. /// The service used to send back-channel logout notifications to clients when the user signs out. + /// The service for handling user session events /// The telemetry service /// A task that completes when the request has been handled by an IdentityServer endpoint or passed to the next middleware in the pipeline. public async Task Invoke( @@ -47,7 +50,8 @@ public async Task Invoke( IEndpointRouter router, IUserSession session, IEventService events, - IBackChannelLogoutService backChannelLogoutService, + IBackChannelLogoutService backChannelLogoutService, + IUserSessionEventsService userSessionEventsService, ITelemetryService telemetryService) { // this will check the authentication session and from it emit the check session @@ -62,13 +66,16 @@ public async Task Invoke( // this clears our session id cookie so JS clients can detect the user has signed out await session.RemoveSessionIdCookieAsync(); - - // back channel logout - var logoutContext = await session.GetLogoutNotificationContext(); - if (logoutContext != null) + + // notify other services of logout when required + var user = await session.GetUserAsync(); + var clientIds = await session.GetClientListAsync(); + await userSessionEventsService.HandleUserSessionLogout(new UserSessionEventContext { - await backChannelLogoutService.SendLogoutNotificationsAsync(logoutContext); - } + SessionId = await session.GetSessionIdAsync(), + SubjectId = user.GetSubjectId(), + ClientIds = clientIds.ToArray(), + }); } }); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Hosting/IdentityServerMiddlewareTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Hosting/IdentityServerMiddlewareTests.cs index a93d1bf1e..8a788c2d4 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Hosting/IdentityServerMiddlewareTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Hosting/IdentityServerMiddlewareTests.cs @@ -1,13 +1,20 @@ // Copyright (c) 2026, Rock Solid Knowledge Ltd +// Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. +#nullable enable + using System; +using System.Security.Claims; using System.Threading.Tasks; using AwesomeAssertions; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.Extensions.Logging; using Moq; +using Open.IdentityServer.Extensions; using Open.IdentityServer.Hosting; +using Open.IdentityServer.Models; using Open.IdentityServer.Services; using Xunit; @@ -25,6 +32,7 @@ public class IdentityServerMiddlewareTests private readonly Mock _userSession; private readonly Mock _eventService; private readonly Mock _backChannelLogoutService; + private readonly IUserSessionEventsService userSessionEventsService; private readonly Mock _telemetryService; private readonly Mock _trace; private readonly DefaultHttpContext _context; @@ -41,6 +49,7 @@ public IdentityServerMiddlewareTests() _userSession = new Mock(); _eventService = new Mock(); _backChannelLogoutService = new Mock(); + userSessionEventsService = Mock.Of(); _telemetryService = new Mock(); _trace = new Mock(); _telemetryService.Setup(t => t.Trace(It.IsAny(), It.IsAny())) @@ -57,7 +66,7 @@ public IdentityServerMiddlewareTests() private async Task InvokeSubjectMiddleware() { await _subject.Invoke(_context, _router.Object, _userSession.Object, - _eventService.Object, _backChannelLogoutService.Object, _telemetryService?.Object); + _eventService.Object, _backChannelLogoutService.Object, userSessionEventsService, _telemetryService?.Object); } [Fact] @@ -208,4 +217,50 @@ public async Task Invoke_WhenRouterLocatesEndpoint_ShouldTrackActiveRequest() Times.Once); activeRequestDisposable.Verify(x => x.Dispose(), Times.Once); } + + [Fact] + public async Task Invoke_WhenSignOutCalled_ShouldCallHandleUserSessionLogout_OnIUserSessionEventsService() + { + string sessionId = "session-id"; + string subjectId = "subject-id"; + string[] clientIds = ["clientId1", "clientId2", "clientId3"]; + ClaimsPrincipal user = new ClaimsPrincipal(new ClaimsIdentity([ + new Claim(JwtClaimTypes.Subject, subjectId, ClaimValueTypes.String, "FakeIssuer"), + ])); + + // Manually invoke the OnStarting callback to test that user session events service is called + var responseFeatureMock = Mock.Of(); + Mock.Get(responseFeatureMock) + .Setup(x => x.OnStarting(It.IsAny>(), It.IsAny())) + .Callback, object>((callback, state) => { callback.Invoke(state); }); + _context.Features[typeof(IHttpResponseFeature)] = responseFeatureMock; + + _context.SetSignOutCalled(); + + _userSession.Setup(x => x.GetClientListAsync()) + .ReturnsAsync(clientIds); + _userSession.Setup(x => x.GetSessionIdAsync()) + .ReturnsAsync(sessionId); + _userSession.Setup(x => x.GetUserAsync()) + .ReturnsAsync(user); + + _userSession.Setup(x => x.RemoveSessionIdCookieAsync()).Returns(Task.CompletedTask); + + UserSessionEventContext? actualUserSessionEventCtx = null; + Mock.Get(userSessionEventsService) + .Setup(x => x.HandleUserSessionLogout(It.IsAny())) + .Callback((sessionEventContext) => { actualUserSessionEventCtx = sessionEventContext; }); + + await InvokeSubjectMiddleware(); + + _userSession.Verify(x => x.RemoveSessionIdCookieAsync(), Times.Once); + + Mock.Get(userSessionEventsService) + .Verify(x => x.HandleUserSessionLogout(It.IsAny()), Times.Once); + + actualUserSessionEventCtx.Should().NotBeNull(); + actualUserSessionEventCtx.SessionId.Should().BeEquivalentTo(sessionId); + actualUserSessionEventCtx.SubjectId.Should().BeEquivalentTo(subjectId); + actualUserSessionEventCtx.ClientIds.Should().BeEquivalentTo(clientIds); + } } \ No newline at end of file From a7da644167de4853f429bbca3bbdc30a53c9d9cc Mon Sep 17 00:00:00 2001 From: James Britton Date: Tue, 25 Aug 2026 14:21:23 +0100 Subject: [PATCH 21/59] fix: broken tests due to missing dependency when server side sessions disabled --- .../DependencyInjection/BuilderExtensions/Additional.cs | 2 +- .../DependencyInjection/BuilderExtensions/Core.cs | 2 ++ .../src/Hosting/IdentityServerMiddleware.cs | 7 ++++++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs index 96bd21406..da0abc1a2 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs @@ -474,7 +474,7 @@ public static IIdentityServerBuilder AddServerSideSessions(this IIdentityServerB { builder.Services.AddSingleton, PostConfigureSessionStoreCookieAuthOptions>(); builder.Services.AddScoped(); - builder.Services.AddScoped(); + // builder.Services.AddScoped(); // provide default in-memory implementation, not suitable for most production scenarios (following pattern implemented with existing stores) builder.Services.TryAddSingleton(); diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs index 7cd2d4007..cf62e4275 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Core.cs @@ -136,6 +136,8 @@ public static IIdentityServerBuilder AddCoreServices(this IIdentityServerBuilder builder.Services.AddCors(); builder.Services.AddTransientDecorator(); + + builder.Services.AddScoped(); return builder; } diff --git a/src/Open.IdentityServer/src/Hosting/IdentityServerMiddleware.cs b/src/Open.IdentityServer/src/Hosting/IdentityServerMiddleware.cs index 895364ed9..38d4cb4a5 100644 --- a/src/Open.IdentityServer/src/Hosting/IdentityServerMiddleware.cs +++ b/src/Open.IdentityServer/src/Hosting/IdentityServerMiddleware.cs @@ -2,7 +2,6 @@ // Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. - using Open.IdentityServer.Events; using Open.IdentityServer.Extensions; using Open.IdentityServer.Services; @@ -70,6 +69,12 @@ public async Task Invoke( // notify other services of logout when required var user = await session.GetUserAsync(); var clientIds = await session.GetClientListAsync(); + + if (user == null) + { + return; + } + await userSessionEventsService.HandleUserSessionLogout(new UserSessionEventContext { SessionId = await session.GetSessionIdAsync(), From fb9fb7ae599724104adc6d38f375ec690bdffd55 Mon Sep 17 00:00:00 2001 From: James Britton Date: Fri, 28 Aug 2026 13:03:36 +0100 Subject: [PATCH 22/59] pr: correcting issues spotted in review --- .../src/Stores/PersistedGrantStore.cs | 22 ------------------- .../BuilderExtensions/Additional.cs | 1 - .../Options/AuthenticationOptions.cs | 2 +- .../Options/ServerSideSessionsOptions.cs | 6 +++-- .../Contexts/UserSessionEventContext.cs | 8 +++---- src/Storage/src/Models/Client.cs | 7 +++++- 6 files changed, 15 insertions(+), 31 deletions(-) diff --git a/src/EntityFramework.Storage/src/Stores/PersistedGrantStore.cs b/src/EntityFramework.Storage/src/Stores/PersistedGrantStore.cs index cb84c24e4..a109e94e8 100644 --- a/src/EntityFramework.Storage/src/Stores/PersistedGrantStore.cs +++ b/src/EntityFramework.Storage/src/Stores/PersistedGrantStore.cs @@ -101,7 +101,6 @@ public virtual async Task GetAsync(string key) public async Task> GetAllAsync(PersistedGrantFilter filter) { using var trace = Telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); - AddFilterTags(trace, filter); filter.Validate(); @@ -147,7 +146,6 @@ public virtual async Task RemoveAsync(string key) public async Task RemoveAllAsync(PersistedGrantFilter filter) { using var trace = Telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); - AddFilterTags(trace, filter); filter.Validate(); @@ -168,26 +166,6 @@ public async Task RemoveAllAsync(PersistedGrantFilter filter) } } - private void AddFilterTags(ITrace trace, PersistedGrantFilter filter) - { - if (trace == null) return; - - var clientIds = filter.ClientIds.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); - if (clientIds.Any()) - { - trace.AddTag(TelemetryConstants.TagConstants.Client, string.Join(",", filter.ClientIds)); - } - if (!string.IsNullOrWhiteSpace(filter.SubjectId)) - { - trace.AddTag(TelemetryConstants.TagConstants.Subject, filter.SubjectId); - } - var types = filter.Types.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); - if (types.Any()) - { - trace.AddTag(TelemetryConstants.TagConstants.GrantType, string.Join(",", filter.Types)); - } - } - private IQueryable Filter(IQueryable query, PersistedGrantFilter filter) { var clientIds = filter.ClientIds.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(); diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs index da0abc1a2..aad5d339b 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs @@ -474,7 +474,6 @@ public static IIdentityServerBuilder AddServerSideSessions(this IIdentityServerB { builder.Services.AddSingleton, PostConfigureSessionStoreCookieAuthOptions>(); builder.Services.AddScoped(); - // builder.Services.AddScoped(); // provide default in-memory implementation, not suitable for most production scenarios (following pattern implemented with existing stores) builder.Services.TryAddSingleton(); diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/AuthenticationOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/AuthenticationOptions.cs index 28d72c895..848025a8f 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/AuthenticationOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/AuthenticationOptions.cs @@ -62,7 +62,7 @@ public class AuthenticationOptions public bool RequireCspFrameSrcForSignout { get; set; } = true; /// - /// If set, refresh token lifetimes will be tied to the users' session. This setting can be overridden at a client + /// If set, refresh token lifetimes will be tied to the users' session. This setting can be overridden at the client /// level. /// public bool CoordinateClientLifetimesWithUserSession { get; set; } diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs index 76b5f41e2..e9c0b12e5 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs @@ -4,12 +4,14 @@ namespace Open.IdentityServer.Configuration; /// -/// +/// Server side sessions options. /// public class ServerSideSessionsOptions { /// - /// + /// Specifies if session expiry should trigger back channel logout, this will override any other settings that may + /// cause back channel logout such as AuthenticationOptions.CoordinateClientLifetimesWithUserSession or + /// Client.CoordinateLifetimeWithUserSession. /// public bool ExpiredSessionsTriggerBackchannelLogout { get; set; } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs b/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs index b002b3cc3..cdece2b86 100644 --- a/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs +++ b/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs @@ -4,22 +4,22 @@ namespace Open.IdentityServer.Models; /// -/// Provides the context necessary to handle user session events +/// Provides the context for handling user session events /// public class UserSessionEventContext { /// - /// Subject identifier of the user of the session the event has been triggered for + /// Subject identifier of the User of the session for which the event has been triggered. /// public string SubjectId { get; set; } /// - /// Session identifier of the session the event has been triggered for + /// Session identifier for the event that has been triggered. /// public string SessionId { get; set; } /// - /// ClientIds logged into with the user session + /// Collection of ClientId active within the session. /// public string[] ClientIds { get; set; } = []; } \ No newline at end of file diff --git a/src/Storage/src/Models/Client.cs b/src/Storage/src/Models/Client.cs index 025e36956..94d4bdfd5 100644 --- a/src/Storage/src/Models/Client.cs +++ b/src/Storage/src/Models/Client.cs @@ -432,8 +432,13 @@ IEnumerator IEnumerable.GetEnumerator() } /// - /// Gets or sets the coordinate lifetime with the user session + /// Used to override the server default value configured with IdentityServerOptions.Authentication.CoordinateClientLifetimesWithUserSession. + /// Specifies if the user session ending should revoke client revocable tokens, and also if token validation should + /// check for a valid user session. /// + /// + /// true if coordination enabled; false if coordination disabled; otherwise, null. + /// public bool? CoordinateLifetimeWithUserSession { get; set; } //Unused Compatibility Properties From 235219778c9591aa406efe49108753d786791a6f Mon Sep 17 00:00:00 2001 From: James Britton Date: Fri, 28 Aug 2026 13:03:36 +0100 Subject: [PATCH 23/59] pr: correcting issues spotted in review --- .../src/Models/Contexts/UserSessionEventContext.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs b/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs index cdece2b86..c3b82cd64 100644 --- a/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs +++ b/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs @@ -14,7 +14,7 @@ public class UserSessionEventContext public string SubjectId { get; set; } /// - /// Session identifier for the event that has been triggered. + /// Session identifier of the session the event has been triggered for /// public string SessionId { get; set; } From da28f566c97fb8006258f2f0a91092429c347246 Mon Sep 17 00:00:00 2001 From: James Britton Date: Mon, 24 Aug 2026 09:37:41 +0100 Subject: [PATCH 24/59] feat: added filter methods for session stores --- .../IdentityServerServerSideSessionStore.cs | 18 +++ ...entityServerServerSideSessionStoreTests.cs | 52 +++++++- .../src/Hosting/IdentityServerMiddleware.cs | 2 +- .../AuthenticationTicketFilterResult.cs | 26 ++++ .../Default/ServerSessionTicketStore.cs | 61 +++++++--- .../src/Stores/IServerSessionTicketStore.cs | 21 ++++ .../Stores/InMemory/InMemorySessionStore.cs | 9 ++ .../BuilderExtensions/AdditionalTests.cs | 6 + .../Default/ServerSessionTicketStoreTests.cs | 115 +++++++++++++++++- .../Stores/InMemorySessionStoreTests.cs | 53 ++++++++ .../IIdentityServerServerSideSessionStore.cs | 11 +- 11 files changed, 351 insertions(+), 23 deletions(-) create mode 100644 src/Open.IdentityServer/src/Models/AuthenticationTicketFilterResult.cs create mode 100644 src/Open.IdentityServer/src/Stores/IServerSessionTicketStore.cs diff --git a/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs b/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs index ee7eb6e5a..1f198940e 100644 --- a/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs +++ b/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs @@ -4,6 +4,8 @@ #nullable enable using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -121,4 +123,20 @@ public async Task DeleteSession(string key) logger.LogError(ex, "exception deleting '{SessionKey}' session in database", key); } } + + /// + public async Task> FilterSessions(string subjectId, string sessionId) + { + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + + ArgumentException.ThrowIfNullOrWhiteSpace(subjectId); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + + var result = dbContext.ServerSideSessions.ToList(); + + return await dbContext.ServerSideSessions + .Where(x => x.SubjectId == subjectId && x.SessionId == sessionId) + .Select(x => x.ToModel()) + .ToListAsync(); + } } \ No newline at end of file diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs index b0207b40c..80146b937 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs @@ -298,18 +298,64 @@ public async Task DeleteSession_WhenSessionExistsWithKey_ShouldDeleteStoredSessi stored.Should().BeNull(); } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FilterSessions_WhenSessionDontMatch_ShouldReturnEmptySet(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("john", "session-x")).ToList(); + + actual.Should().BeEmpty(); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FilterSessions_WhenSessionMatch_ShouldReturnMatchingSessions(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", "session-1")).ToList(); + + actual.Should().HaveCount(2); + actual.Should().Contain(x => x.Key == "key-1"); + actual.Should().Contain(x => x.Key == "key-6"); + } [Theory, MemberData(nameof(TestDatabaseProviders))] public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace(DbContextOptions options) { List<(Func actMethod, string traceMethodName)> methods - = new() - { + = [ (store => store.CreateSession(new SessionModel { Key = "FAKE_SESSION_KEY" }), "CreateSession"), (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.FilterSessions("FAKE_SUBJECT_KEY", "FAKE_SESSION_KEY"), "FilterSessions"), + ]; foreach (var method in methods) { diff --git a/src/Open.IdentityServer/src/Hosting/IdentityServerMiddleware.cs b/src/Open.IdentityServer/src/Hosting/IdentityServerMiddleware.cs index 38d4cb4a5..776e54b83 100644 --- a/src/Open.IdentityServer/src/Hosting/IdentityServerMiddleware.cs +++ b/src/Open.IdentityServer/src/Hosting/IdentityServerMiddleware.cs @@ -75,7 +75,7 @@ public async Task Invoke( return; } - await userSessionEventsService.HandleUserSessionLogout(new UserSessionEventContext + await userSessionEventsService.HandleUserSessionLogout(new EndUserSessionEventContext { SessionId = await session.GetSessionIdAsync(), SubjectId = user.GetSubjectId(), diff --git a/src/Open.IdentityServer/src/Models/AuthenticationTicketFilterResult.cs b/src/Open.IdentityServer/src/Models/AuthenticationTicketFilterResult.cs new file mode 100644 index 000000000..42feca41c --- /dev/null +++ b/src/Open.IdentityServer/src/Models/AuthenticationTicketFilterResult.cs @@ -0,0 +1,26 @@ +// 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 Microsoft.AspNetCore.Authentication; +using Open.IdentityServer.Stores; + +namespace Open.IdentityServer.Models; + +/// +/// Object for containing the session object and deseriakised auth ticket from the +/// FilterServerAuthenticationTickets methods +/// +public class AuthenticationTicketFilterResult +{ + /// + /// Session entity from the database + /// + public IdentityServerServerSideSessions Session { get; set; } = null!; + + /// + /// AuthenticationTicket deserialized from the data property on the session entity + /// + public AuthenticationTicket? AuthTicket { get; set; } +} \ 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 e5f6f4b9f..333854a3c 100644 --- a/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs +++ b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs @@ -4,6 +4,8 @@ #nullable enable using System; +using System.Collections.Generic; +using System.Linq; using System.Security.Claims; using System.Text.Json; using System.Threading.Tasks; @@ -33,7 +35,7 @@ public class ServerSessionTicketStore( IDataProtectionProvider dataProtectionProvider, TimeProvider timeProvider, ITelemetryService telemetry, - ILogger logger) : ITicketStore + ILogger logger): IServerSessionTicketStore { private readonly IDataProtector dataProtector = dataProtectionProvider.CreateProtector(DataProtectionConstants.ServerSideTicketStorePurpose); @@ -107,20 +109,7 @@ public async Task RenewAsync(string key, AuthenticationTicket ticket) try { - DataProtectedSessionData? dataProtectedSessionData = JsonSerializer.Deserialize(existingSession.Data, JsonSettings); - - if (dataProtectedSessionData is not { Version: 1 }) - { - logger.LogError("failed retrieving '{SessionKey}', deserialisation failed, incorrect version '{VersionOrNull}'", key, dataProtectedSessionData?.Version); - return null; - } - - string unprotectedData = dataProtector.Unprotect(dataProtectedSessionData.Payload); - - SerializedAuthenticationTicket? serializedAuthTicket = - JsonSerializer.Deserialize(unprotectedData); - - return serializedAuthTicket?.ToAuthTicket(); + return DeserializeAuthTicket(existingSession); } catch (Exception ex) { @@ -140,6 +129,20 @@ public Task RemoveAsync(string key) return Task.CompletedTask; } + /// + public async Task> FilterServerAuthenticationTickets(string subjectId, string sessionId) + { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + + IEnumerable sessions = await serverServerSideSessionStore.FilterSessions(subjectId, sessionId); + + return sessions.Select(x => new AuthenticationTicketFilterResult + { + Session = x, + AuthTicket = DeserializeAuthTicket(x), + }).Where(x => x.AuthTicket != null); + } + private async Task StoreNewSession(string key, AuthenticationTicket ticket) { IdentityServerServerSideSessions serverSideSession = new IdentityServerServerSideSessions @@ -169,4 +172,32 @@ private string ToProtectedDataString(AuthenticationTicket ticket) Payload = dataProtector.Protect(serializedTicket), }, JsonSettings); } + + private AuthenticationTicket? DeserializeAuthTicket(IdentityServerServerSideSessions existingSession) + { + DataProtectedSessionData? dataProtectedSessionData; + + try + { + dataProtectedSessionData = JsonSerializer.Deserialize(existingSession.Data, JsonSettings); + } + catch (JsonException exception) + { + logger.LogError(exception, "failed deserialising auth ticket data"); + return null; + } + + if (dataProtectedSessionData is not { Version: 1 }) + { + logger.LogError("failed retrieving '{SessionKey}', deserialisation failed, incorrect version '{VersionOrNull}'", existingSession.Key, dataProtectedSessionData?.Version); + return null; + } + + string unprotectedData = dataProtector.Unprotect(dataProtectedSessionData.Payload); + + SerializedAuthenticationTicket? serializedAuthTicket = + JsonSerializer.Deserialize(unprotectedData); + + return serializedAuthTicket?.ToAuthTicket(); + } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Stores/IServerSessionTicketStore.cs b/src/Open.IdentityServer/src/Stores/IServerSessionTicketStore.cs new file mode 100644 index 000000000..7b46a962a --- /dev/null +++ b/src/Open.IdentityServer/src/Stores/IServerSessionTicketStore.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Open.IdentityServer.Models; + +namespace Open.IdentityServer.Stores; + +/// +/// Extension to to add more methods of retrival +/// +public interface IServerSessionTicketStore: ITicketStore +{ + /// + /// 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); +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs b/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs index 85181f8e5..03ee23881 100644 --- a/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs +++ b/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs @@ -4,6 +4,8 @@ #nullable enable using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Open.IdentityServer.Models; @@ -43,4 +45,11 @@ public Task DeleteSession(string key) repo.TryRemove(key, out IdentityServerServerSideSessions? value); return Task.CompletedTask; } + + /// + public Task> FilterSessions(string subjectId, string sessionId) + { + return Task.FromResult(repo.Values + .Where(x => x.SubjectId == subjectId && x.SessionId == sessionId)); + } } \ No newline at end of file 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 a829272b2..0d2d2379a 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 @@ -1,6 +1,7 @@ // 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.Collections.Generic; using System.Threading.Tasks; using AwesomeAssertions; using Microsoft.AspNetCore.Authentication.Cookies; @@ -87,4 +88,9 @@ public Task DeleteSession(string key) { throw new System.NotImplementedException(); } + + public Task> FilterSessions(string subjectId, string sessionId) + { + throw new System.NotImplementedException(); + } } \ 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 0231022e6..942ca1f0b 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 @@ -394,6 +394,78 @@ public async Task RemoveAsync_ShouldCallServerSideSessionStoreDelete() .Verify(x => x.DeleteSession(keyId)); } + [Theory] + [InlineData("{invalid.json}")] + [InlineData("{\"Version\": 2, Payload: \"SOMEDATA\"}")] + public async Task FilterServerAuthenticationTickets_WhenSessionDataDeserialisationFails_ShouldReturnSkipFailingAuthTicket(string data) + { + const string testSubjectId = "bob"; + 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"), + ]; + + List expectedAuthTickets = []; + sessions = sessions.Select(session => GenerateSerialisedData(expectedAuthTickets, session)); + + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.FilterSessions(testSubjectId, testSessionId)) + .ReturnsAsync(sessions); + + ServerSessionTicketStore sut = CreateSut(); + IEnumerable actual = + (await sut.FilterServerAuthenticationTickets(testSubjectId, testSessionId)).ToList(); + + actual.Should().NotBeNullOrEmpty(); + actual.Should().HaveCount(expectedAuthTickets.Count); + } + + [Fact] + public async Task FilterServerAuthenticationTickets_WhenNotingReturnedByStore_ShouldReturnEmptyCollection() + { + const string testSubjectId = "bob"; + const string testSessionId = "session-0"; + + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.FilterSessions(testSubjectId, testSessionId)) + .ReturnsAsync([]); + + ServerSessionTicketStore sut = CreateSut(); + IEnumerable actual = await sut.FilterServerAuthenticationTickets(testSubjectId, testSessionId); + + actual.Should().BeEmpty(); + } + + [Fact] + public async Task FilterServerAuthenticationTickets_WhenSessionReturnedFromStore_ShouldReturnExtractedAuthTickets() + { + const string testSubjectId = "bob"; + 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"), + ]; + + List expectedAuthTickets = []; + sessions = sessions.Select(x => GenerateSerialisedData(expectedAuthTickets, x)); + + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.FilterSessions(testSubjectId, testSessionId)) + .ReturnsAsync(sessions); + + ServerSessionTicketStore sut = CreateSut(); + IEnumerable actual = + (await sut.FilterServerAuthenticationTickets(testSubjectId, testSessionId)).ToList(); + + actual.Should().NotBeNullOrEmpty(); + actual.Should().HaveCount(expectedAuthTickets.Count); + } + [Fact] public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace() { @@ -401,12 +473,12 @@ public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace() GenerateAuthenticationTicket("FakeScheme", Guid.NewGuid().ToString(), Guid.NewGuid().ToString()); List<(Func actMethod, string traceMethodName)> methods - = - [ + = [ (store => store.StoreAsync(authTicket), "StoreAsync"), (store => store.RenewAsync("FAKE_KEY", authTicket), "RenewAsync"), (store => store.RetrieveAsync("FAKE_KEY"), "RetrieveAsync"), - (store => store.RemoveAsync("FAKE_KEY"), "RemoveAsync") + (store => store.RemoveAsync("FAKE_KEY"), "RemoveAsync"), + (store => store.FilterServerAuthenticationTickets("FAKE_SUB_KEY", "FAKE_SESSION_KEY"), "FilterServerAuthenticationTickets"), ]; var sut = CreateSut(); @@ -436,6 +508,23 @@ public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace() .Should().BeEquivalentTo(methods.Select(m => m.traceMethodName)); } + private IdentityServerServerSideSessions GenerateSerialisedData( + List expectedAuthTickets, + IdentityServerServerSideSessions identityServerServerSideSessions) + { + if (string.IsNullOrWhiteSpace(identityServerServerSideSessions.Data)) + { + SerializedAuthenticationTicket authenticationTicket = GenerateSerializedAuthenticationTicket( + identityServerServerSideSessions.Scheme, identityServerServerSideSessions.SubjectId, identityServerServerSideSessions.SessionId, + identityServerServerSideSessions.DisplayName, identityServerServerSideSessions.Renewed, identityServerServerSideSessions.Expires); + identityServerServerSideSessions.Data = GenerateFakeData(authenticationTicket); + + expectedAuthTickets.Add(authenticationTicket); + } + + return identityServerServerSideSessions; + } + private AuthenticationTicket GenerateAuthenticationTicket(string authScheme, string? subjectId, string? sessionId, string? displayName = null, DateTimeOffset? issuedUtc = null, DateTimeOffset? expiresUtc = null) { @@ -506,4 +595,24 @@ private SerializedAuthenticationTicket GenerateSerializedAuthenticationTicket(st 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/Open.IdentityServer.UnitTests/Stores/InMemorySessionStoreTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemorySessionStoreTests.cs index ff36e5caf..2fd90f320 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemorySessionStoreTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemorySessionStoreTests.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using AwesomeAssertions; using Open.IdentityServer.Models; @@ -206,4 +207,56 @@ public async Task DeleteSession_WhenSessionExists_ShouldBeRemoved() IdentityServerServerSideSessions? actual = await sut.GetSession(testKey); actual.Should().BeNull(); } + + [Fact] + public async Task FilterSessions_WhenSessionDontMatch_ShouldReturnEmptySet() + { + 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("john", "session-x")).ToList(); + + actual.Should().BeEmpty(); + } + + [Fact] + public async Task FilterSessions_WhenNoSessionsStored_ShouldReturnEmptySet() + { + InMemorySessionStore sut = CreateSut(); + + var actual = (await sut.FilterSessions("john", "session-x")).ToList(); + + actual.Should().BeEmpty(); + } + + [Fact] + public async Task FilterSessions_WhenSessionMatch_ShouldReturnMatchingSessions() + { + 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", "session-1")).ToList(); + + actual.Should().HaveCount(2); + actual.Should().Contain(x => x.Key == "key-1"); + actual.Should().Contain(x => x.Key == "key-6"); + } } \ No newline at end of file diff --git a/src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs b/src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs index 6518e09a3..69f620bb7 100644 --- a/src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs +++ b/src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs @@ -3,6 +3,7 @@ #nullable enable +using System.Collections.Generic; using System.Threading.Tasks; using Open.IdentityServer.Models; @@ -28,7 +29,7 @@ public interface IIdentityServerServerSideSessionStore public Task CreateSession(IdentityServerServerSideSessions session); /// - /// Updates the provided server side session model, model with unique key must already exist in store + /// Updates the provided server-side session model. The model with a unique key must already exist in the store /// /// session model to update /// void @@ -40,4 +41,12 @@ public interface IIdentityServerServerSideSessionStore /// unique key of session /// void public Task DeleteSession(string key); + + /// + /// Filters auth tickets stored in 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); } \ No newline at end of file From 898a9da77f3c9d67509183d5e9426150e6626a0a Mon Sep 17 00:00:00 2001 From: James Britton Date: Mon, 24 Aug 2026 09:38:13 +0100 Subject: [PATCH 25/59] feat: added method for handling token validation for server-side sessions --- .../ConfigureInternalCookieOptions.cs | 2 + .../src/IdentityServerConstants.cs | 2 + .../Contexts/EndUserSessionEventContext.cs | 12 + .../Contexts/UserSessionEventContext.cs | 5 - .../ValidateUserSessionEventContext.cs | 12 + .../DefaultUserSessionEventsService.cs | 64 ++- .../src/Services/IUserSessionEventsService.cs | 17 +- .../ServerSideSessionTokenValidator.cs | 37 ++ .../Hosting/IdentityServerMiddlewareTests.cs | 8 +- .../DefaultUserSessionEventsServiceTests.cs | 366 ++++++++++++++++-- 10 files changed, 479 insertions(+), 46 deletions(-) create mode 100644 src/Open.IdentityServer/src/Models/Contexts/EndUserSessionEventContext.cs create mode 100644 src/Open.IdentityServer/src/Models/Contexts/ValidateUserSessionEventContext.cs create mode 100644 src/Open.IdentityServer/src/Validation/Default/ServerSideSessionTokenValidator.cs diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/ConfigureInternalCookieOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/ConfigureInternalCookieOptions.cs index a631df740..502cc8e81 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/ConfigureInternalCookieOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/ConfigureInternalCookieOptions.cs @@ -38,6 +38,8 @@ public void Configure(string name, CookieAuthenticationOptions options) { options.ReturnUrlParameter = _idsrv.UserInteraction.LoginReturnUrlParameter; } + + // options.Events.OnCheckSlidingExpiration = TODO implement to use a flag in the auth ticket properties to decide if the cookie needs refreshing } if (name == IdentityServerConstants.ExternalCookieAuthenticationScheme) diff --git a/src/Open.IdentityServer/src/IdentityServerConstants.cs b/src/Open.IdentityServer/src/IdentityServerConstants.cs index 6a7dbe287..aec6bdea6 100644 --- a/src/Open.IdentityServer/src/IdentityServerConstants.cs +++ b/src/Open.IdentityServer/src/IdentityServerConstants.cs @@ -1,4 +1,5 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. #pragma warning disable 1591 @@ -16,6 +17,7 @@ public static class IdentityServerConstants public const string ExternalCookieAuthenticationScheme = "idsrv.external"; public const string DefaultCheckSessionCookieName = "idsrv.session"; public const string AccessTokenAudience = "{0}resources"; + public const string ForceCookieRefresh = "Open.IdentityServer.ForceCookieRefresh"; public const string JwtRequestClientKey = "idsrv.jwtrequesturi.client"; diff --git a/src/Open.IdentityServer/src/Models/Contexts/EndUserSessionEventContext.cs b/src/Open.IdentityServer/src/Models/Contexts/EndUserSessionEventContext.cs new file mode 100644 index 000000000..cff844712 --- /dev/null +++ b/src/Open.IdentityServer/src/Models/Contexts/EndUserSessionEventContext.cs @@ -0,0 +1,12 @@ +namespace Open.IdentityServer.Models; + +/// +/// Provides the context necessary to handle end of user session events +/// +public class EndUserSessionEventContext: UserSessionEventContext +{ + /// + /// Collection of ClientId active within the session. + /// + public string[] ClientIds { get; set; } = []; +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs b/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs index c3b82cd64..93363b400 100644 --- a/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs +++ b/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs @@ -17,9 +17,4 @@ public class UserSessionEventContext /// Session identifier of the session the event has been triggered for /// public string SessionId { get; set; } - - /// - /// Collection of ClientId active within the session. - /// - public string[] ClientIds { get; set; } = []; } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Models/Contexts/ValidateUserSessionEventContext.cs b/src/Open.IdentityServer/src/Models/Contexts/ValidateUserSessionEventContext.cs new file mode 100644 index 000000000..12f166c08 --- /dev/null +++ b/src/Open.IdentityServer/src/Models/Contexts/ValidateUserSessionEventContext.cs @@ -0,0 +1,12 @@ +namespace Open.IdentityServer.Models; + +/// +/// Provides the context necessary to validate user session events +/// +public class ValidateUserSessionEventContext: UserSessionEventContext +{ + /// + /// + /// + public Client Client { get; set; } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs b/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs index 223070175..1d5f288b4 100644 --- a/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs +++ b/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs @@ -20,19 +20,25 @@ namespace Open.IdentityServer.Services.Default; /// client store /// persisted grant store /// back channel logout service +/// server side auth ticket store +/// server side session entity store /// IdentityServer options /// telemetry service +/// time provider /// logger public class DefaultUserSessionEventsService( IClientStore clientStore, IPersistedGrantStore persistedGrantStore, IBackChannelLogoutService backChannelLogoutService, + IServerSessionTicketStore? serverSessionTicketStore, + IIdentityServerServerSideSessionStore? identityServerServerSideSessionStore, IdentityServerOptions idsOptions, ITelemetryService telemetry, + TimeProvider timeProvider, ILogger logger) : IUserSessionEventsService { /// - public async Task HandleUserSessionLogout(UserSessionEventContext sessionEventContext) + public async Task HandleUserSessionLogout(EndUserSessionEventContext sessionEventContext) { ArgumentException.ThrowIfNullOrWhiteSpace(sessionEventContext.SessionId); ArgumentException.ThrowIfNullOrWhiteSpace(sessionEventContext.SubjectId); @@ -56,7 +62,7 @@ await backChannelLogoutService.SendLogoutNotificationsAsync(new LogoutNotificati } /// - public async Task HandleUserSessionExpiry(UserSessionEventContext sessionEventContext) + public async Task HandleUserSessionExpiry(EndUserSessionEventContext sessionEventContext) { ArgumentException.ThrowIfNullOrWhiteSpace(sessionEventContext.SessionId); ArgumentException.ThrowIfNullOrWhiteSpace(sessionEventContext.SubjectId); @@ -83,7 +89,51 @@ await backChannelLogoutService.SendLogoutNotificationsAsync(new LogoutNotificati }); } - private async Task EndSessionForClients(UserSessionEventContext sessionEventContext) + /// + public async Task ValidateSession(ValidateUserSessionEventContext sessionEventContext) + { + if (serverSessionTicketStore == null || identityServerServerSideSessionStore == null || + !ShouldCoordinateLifetimes(sessionEventContext.Client)) + { + return true; + } + + var sessions = + (await serverSessionTicketStore.FilterServerAuthenticationTickets(sessionEventContext.SubjectId, + sessionEventContext.SessionId)) + .ToList(); + + if (sessions.Count == 0 || sessions.All(x => x.Session.Expires.HasValue && + x.Session.Expires < timeProvider.GetUtcNow())) + { + logger.LogDebug(""); + return false; + } + + foreach (var session in sessions) + { + var diff = session.Session.Expires - session.Session.Renewed; + session.Session.Renewed = timeProvider.GetUtcNow().UtcDateTime; + session.Session.Expires = session.Session.Renewed + diff; + + if (idsOptions.Authentication.CookieSlidingExpiration && + session.AuthTicket?.Properties is { IsPersistent: true, AllowRefresh: true or null }) + { + session.AuthTicket.Properties.IssuedUtc = session.Session.Renewed; + session.AuthTicket.Properties.ExpiresUtc = session.Session.Expires; + session.AuthTicket.Properties.SetString(IdentityServerConstants.ForceCookieRefresh, string.Empty); + await serverSessionTicketStore.RenewAsync(session.Session.Key, session.AuthTicket); + } + else + { + await identityServerServerSideSessionStore.UpdateSession(session.Session); + } + } + + return true; + } + + private async Task EndSessionForClients(EndUserSessionEventContext sessionEventContext) { var clientIds = await ClientIdsToCoordinate(sessionEventContext).ToArrayAsync(); @@ -104,17 +154,19 @@ await persistedGrantStore.RemoveAllAsync(new PersistedGrantFilter return clientIds; } - private async IAsyncEnumerable ClientIdsToCoordinate(UserSessionEventContext sessionEventContext) + private async IAsyncEnumerable ClientIdsToCoordinate(EndUserSessionEventContext sessionEventContext) { foreach (string clientId in sessionEventContext.ClientIds ?? []) { var client = await clientStore.FindClientByIdAsync(clientId); - if (client != null && (client.CoordinateLifetimeWithUserSession ?? - idsOptions.Authentication.CoordinateClientLifetimesWithUserSession)) + if (ShouldCoordinateLifetimes(client)) { yield return client.ClientId; } } } + + private bool ShouldCoordinateLifetimes(Client? client) => client != null && (client.CoordinateLifetimeWithUserSession ?? + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession); } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Services/IUserSessionEventsService.cs b/src/Open.IdentityServer/src/Services/IUserSessionEventsService.cs index 580fb9d2b..81f7fe278 100644 --- a/src/Open.IdentityServer/src/Services/IUserSessionEventsService.cs +++ b/src/Open.IdentityServer/src/Services/IUserSessionEventsService.cs @@ -9,21 +9,28 @@ namespace Open.IdentityServer.Services; /// -/// Service responsible handling user session events +/// Service responsible for handling user session events /// public interface IUserSessionEventsService { /// - /// Triggered when session logout occurs + /// Triggered when the session logout occurs /// /// context needed for handling logout event /// - public Task HandleUserSessionLogout(UserSessionEventContext sessionEventContext); + public Task HandleUserSessionLogout(EndUserSessionEventContext sessionEventContext); /// - /// Triggered when session expires + /// Triggered when the session expires /// /// context needed for handling logout event /// - public Task HandleUserSessionExpiry(UserSessionEventContext sessionEventContext); + public Task HandleUserSessionExpiry(EndUserSessionEventContext sessionEventContext); + + /// + /// Checks for a valid session using the provided context + /// + /// context needed for handling session validation + /// boolean value to indicate if valid session exists + public Task ValidateSession(ValidateUserSessionEventContext sessionEventContext); } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Validation/Default/ServerSideSessionTokenValidator.cs b/src/Open.IdentityServer/src/Validation/Default/ServerSideSessionTokenValidator.cs new file mode 100644 index 000000000..a3613db8a --- /dev/null +++ b/src/Open.IdentityServer/src/Validation/Default/ServerSideSessionTokenValidator.cs @@ -0,0 +1,37 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication.Cookies; +using Open.IdentityServer.Models; +using Open.IdentityServer.Services; + +namespace Open.IdentityServer.Validation; + +/// +/// +/// +/// +/// +public class DefaultServerSideSessionRefreshTokenService( + IRefreshTokenService decoratedService, + ITicketStore ticketStore): IRefreshTokenService +{ + /// + /// + /// + /// + /// + /// + public async Task ValidateRefreshTokenAsync(string token, Client client) + { + var validatedRefreshToken = await decoratedService.ValidateRefreshTokenAsync(token, client); + + throw new System.NotImplementedException(); + } + + /// + public Task CreateRefreshTokenAsync(RefreshTokenCreationRequest request) => + decoratedService.CreateRefreshTokenAsync(request); + + /// + public Task UpdateRefreshTokenAsync(string handle, RefreshToken refreshToken, Client client) => + decoratedService.UpdateRefreshTokenAsync(handle, refreshToken, client); +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Hosting/IdentityServerMiddlewareTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Hosting/IdentityServerMiddlewareTests.cs index 8a788c2d4..29be631a0 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Hosting/IdentityServerMiddlewareTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Hosting/IdentityServerMiddlewareTests.cs @@ -246,17 +246,17 @@ public async Task Invoke_WhenSignOutCalled_ShouldCallHandleUserSessionLogout_OnI _userSession.Setup(x => x.RemoveSessionIdCookieAsync()).Returns(Task.CompletedTask); - UserSessionEventContext? actualUserSessionEventCtx = null; + EndUserSessionEventContext? actualUserSessionEventCtx = null; Mock.Get(userSessionEventsService) - .Setup(x => x.HandleUserSessionLogout(It.IsAny())) - .Callback((sessionEventContext) => { actualUserSessionEventCtx = sessionEventContext; }); + .Setup(x => x.HandleUserSessionLogout(It.IsAny())) + .Callback((sessionEventContext) => { actualUserSessionEventCtx = sessionEventContext; }); await InvokeSubjectMiddleware(); _userSession.Verify(x => x.RemoveSessionIdCookieAsync(), Times.Once); Mock.Get(userSessionEventsService) - .Verify(x => x.HandleUserSessionLogout(It.IsAny()), Times.Once); + .Verify(x => x.HandleUserSessionLogout(It.IsAny()), Times.Once); actualUserSessionEventCtx.Should().NotBeNull(); actualUserSessionEventCtx.SessionId.Should().BeEquivalentTo(sessionId); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs index e6d8ec9ca..d47f7a58a 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs @@ -8,9 +8,12 @@ using System.Linq; using System.Threading.Tasks; using AwesomeAssertions; +using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Time.Testing; using Moq; using Open.IdentityServer.Configuration; +using Open.IdentityServer.Extensions; using Open.IdentityServer.Models; using Open.IdentityServer.Services; using Open.IdentityServer.Services.Default; @@ -25,12 +28,31 @@ public class DefaultUserSessionEventsServiceTests private readonly IBackChannelLogoutService backChannelLogoutService = Mock.Of(); private readonly IClientStore clientStore = Mock.Of(); private readonly IPersistedGrantStore persistedGrantStore = Mock.Of(); + private IServerSessionTicketStore serverSessionTicketStore = Mock.Of(); + private IIdentityServerServerSideSessionStore identityServerServerSideSessionStore = Mock.Of(); private readonly IdentityServerOptions idsOptions = new(); private readonly ITelemetryService telemetry = Mock.Of(); private readonly ITrace trace = Mock.Of(); + private readonly FakeTimeProvider timeProvider = new(); private readonly ILogger logger = TestLogger.Create(); + + private readonly DateTime fakeNow = new(2026, 2, 1, 12, 0, 0, DateTimeKind.Utc); + + public DefaultUserSessionEventsServiceTests() + { + timeProvider.SetUtcNow(fakeNow); + } - private DefaultUserSessionEventsService CreateSut() => new(clientStore, persistedGrantStore, backChannelLogoutService, idsOptions, telemetry, logger); + private DefaultUserSessionEventsService CreateSut() => new( + clientStore, + persistedGrantStore, + backChannelLogoutService, + serverSessionTicketStore, + identityServerServerSideSessionStore, + idsOptions, + telemetry, + timeProvider, + logger); [Theory] [InlineData("subjectId", null)] @@ -41,7 +63,7 @@ public class DefaultUserSessionEventsServiceTests [InlineData(" ", "subjectId")] public async Task HandleUserSessionLogout_WhenInvalidSubjectId_ShouldThrowArgumentException(string subjectId, string sessionId) { - UserSessionEventContext userSessionCtx = new() + EndUserSessionEventContext endUserSessionCtx = new() { SubjectId = subjectId, SessionId = sessionId, @@ -50,7 +72,7 @@ public async Task HandleUserSessionLogout_WhenInvalidSubjectId_ShouldThrowArgume DefaultUserSessionEventsService sut = CreateSut(); - Func act = async () => await sut.HandleUserSessionLogout(userSessionCtx); + Func act = async () => await sut.HandleUserSessionLogout(endUserSessionCtx); await act.Should().ThrowAsync(); @@ -63,7 +85,7 @@ public async Task HandleUserSessionLogout_WhenInvalidSubjectId_ShouldThrowArgume [Fact] public async Task HandleUserSessionLogout_WhenNoClientIdsInSession_ShouldDoNothing() { - UserSessionEventContext userSessionCtx = new() + EndUserSessionEventContext endUserSessionCtx = new() { SubjectId = "fakeSubject", SessionId = "fakeSession", @@ -72,7 +94,7 @@ public async Task HandleUserSessionLogout_WhenNoClientIdsInSession_ShouldDoNothi DefaultUserSessionEventsService sut = CreateSut(); - await sut.HandleUserSessionLogout(userSessionCtx); + await sut.HandleUserSessionLogout(endUserSessionCtx); Mock.Get(persistedGrantStore) .Verify(x => x.RemoveAllAsync(It.IsAny()), Times.Never); @@ -92,7 +114,7 @@ public async Task HandleUserSessionLogout_WhenServerDefaultCoordinateLifetimeSet ]; SetupClientStore(clients); - UserSessionEventContext userSessionCtx = new() + EndUserSessionEventContext endUserSessionCtx = new() { SubjectId = "fakeSubject", SessionId = "fakeSession", @@ -101,7 +123,7 @@ public async Task HandleUserSessionLogout_WhenServerDefaultCoordinateLifetimeSet DefaultUserSessionEventsService sut = CreateSut(); - await sut.HandleUserSessionLogout(userSessionCtx); + await sut.HandleUserSessionLogout(endUserSessionCtx); Mock.Get(persistedGrantStore) .Verify(x => x.RemoveAllAsync(It.Is(x => @@ -134,7 +156,7 @@ public async Task HandleUserSessionLogout_WhenServerDefaultCoordinateLifetimeSet ]; SetupClientStore(clients); - UserSessionEventContext userSessionCtx = new() + EndUserSessionEventContext endUserSessionCtx = new() { SubjectId = "fakeSubject", SessionId = "fakeSession", @@ -143,7 +165,7 @@ public async Task HandleUserSessionLogout_WhenServerDefaultCoordinateLifetimeSet DefaultUserSessionEventsService sut = CreateSut(); - await sut.HandleUserSessionLogout(userSessionCtx); + await sut.HandleUserSessionLogout(endUserSessionCtx); Mock.Get(persistedGrantStore) .Verify(x => x.RemoveAllAsync(It.Is(x => @@ -173,7 +195,7 @@ public async Task HandleUserSessionLogout_WhenClientIdNotFound_ShouldExcludeClie ]; SetupClientStore(clients); - UserSessionEventContext userSessionCtx = new() + EndUserSessionEventContext endUserSessionCtx = new() { SubjectId = "fakeSubject", SessionId = "fakeSession", @@ -182,7 +204,7 @@ public async Task HandleUserSessionLogout_WhenClientIdNotFound_ShouldExcludeClie DefaultUserSessionEventsService sut = CreateSut(); - await sut.HandleUserSessionLogout(userSessionCtx); + await sut.HandleUserSessionLogout(endUserSessionCtx); Mock.Get(persistedGrantStore) .Verify(x => x.RemoveAllAsync(It.Is(x => @@ -210,7 +232,7 @@ public async Task HandleUserSessionLogout_WhenClientIdNotFound_ShouldExcludeClie [InlineData(" ", "subjectId")] public async Task HandleUserSessionExpiry_WhenInvalidSubjectId_ShouldThrowArgumentException(string subjectId, string sessionId) { - UserSessionEventContext userSessionCtx = new() + EndUserSessionEventContext endUserSessionCtx = new() { SubjectId = subjectId, SessionId = sessionId, @@ -219,7 +241,7 @@ public async Task HandleUserSessionExpiry_WhenInvalidSubjectId_ShouldThrowArgume DefaultUserSessionEventsService sut = CreateSut(); - Func act = async () => await sut.HandleUserSessionExpiry(userSessionCtx); + Func act = async () => await sut.HandleUserSessionExpiry(endUserSessionCtx); await act.Should().ThrowAsync(); @@ -232,7 +254,7 @@ public async Task HandleUserSessionExpiry_WhenInvalidSubjectId_ShouldThrowArgume [Fact] public async Task HandleUserSessionExpiry_WhenNoClientIdsInSession_ShouldDoNothing() { - UserSessionEventContext userSessionCtx = new() + EndUserSessionEventContext endUserSessionCtx = new() { SubjectId = "fakeSubject", SessionId = "fakeSession", @@ -241,7 +263,7 @@ public async Task HandleUserSessionExpiry_WhenNoClientIdsInSession_ShouldDoNothi DefaultUserSessionEventsService sut = CreateSut(); - await sut.HandleUserSessionExpiry(userSessionCtx); + await sut.HandleUserSessionExpiry(endUserSessionCtx); Mock.Get(persistedGrantStore) .Verify(x => x.RemoveAllAsync(It.IsAny()), Times.Never); @@ -261,7 +283,7 @@ public async Task HandleUserSessionExpiry_WhenServerDefaultCoordinateLifetimeSet ]; SetupClientStore(clients); - UserSessionEventContext userSessionCtx = new() + EndUserSessionEventContext endUserSessionCtx = new() { SubjectId = "fakeSubject", SessionId = "fakeSession", @@ -270,7 +292,7 @@ public async Task HandleUserSessionExpiry_WhenServerDefaultCoordinateLifetimeSet DefaultUserSessionEventsService sut = CreateSut(); - await sut.HandleUserSessionExpiry(userSessionCtx); + await sut.HandleUserSessionExpiry(endUserSessionCtx); Mock.Get(persistedGrantStore) .Verify(x => x.RemoveAllAsync(It.Is(x => @@ -302,7 +324,7 @@ public async Task HandleUserSessionExpiry_WhenServerDefaultCoordinateLifetimeSet ]; SetupClientStore(clients); - UserSessionEventContext userSessionCtx = new() + EndUserSessionEventContext endUserSessionCtx = new() { SubjectId = "fakeSubject", SessionId = "fakeSession", @@ -311,7 +333,7 @@ public async Task HandleUserSessionExpiry_WhenServerDefaultCoordinateLifetimeSet DefaultUserSessionEventsService sut = CreateSut(); - await sut.HandleUserSessionExpiry(userSessionCtx); + await sut.HandleUserSessionExpiry(endUserSessionCtx); Mock.Get(persistedGrantStore) .Verify(x => x.RemoveAllAsync(It.Is(x => @@ -341,7 +363,7 @@ public async Task HandleUserSessionExpiry_WhenExpiredSessionsTriggerBackchannelL ]; SetupClientStore(clients); - UserSessionEventContext userSessionCtx = new() + EndUserSessionEventContext endUserSessionCtx = new() { SubjectId = "fakeSubject", SessionId = "fakeSession", @@ -350,7 +372,7 @@ public async Task HandleUserSessionExpiry_WhenExpiredSessionsTriggerBackchannelL DefaultUserSessionEventsService sut = CreateSut(); - await sut.HandleUserSessionExpiry(userSessionCtx); + await sut.HandleUserSessionExpiry(endUserSessionCtx); Mock.Get(persistedGrantStore) .Verify(x => x.RemoveAllAsync(It.Is(x => @@ -373,7 +395,7 @@ public async Task HandleUserSessionExpiry_WhenExpiredSessionsTriggerBackchannelL [Fact] public async Task HandleUserSessionExpiry_WhenClientIdNotFound_ShouldExcludeClientIdsNotFound() { - UserSessionEventContext userSessionCtx = new() + EndUserSessionEventContext endUserSessionCtx = new() { SubjectId = "fakeSubject", SessionId = "fakeSession", @@ -382,7 +404,7 @@ public async Task HandleUserSessionExpiry_WhenClientIdNotFound_ShouldExcludeClie DefaultUserSessionEventsService sut = CreateSut(); - await sut.HandleUserSessionExpiry(userSessionCtx); + await sut.HandleUserSessionExpiry(endUserSessionCtx); Mock.Get(persistedGrantStore) .Verify(x => x.RemoveAllAsync(It.IsAny()), Times.Never); @@ -399,7 +421,7 @@ public async Task HandleUserSessionLogout_WhenCalled_ShouldInitiateTelemetryTrac .Returns(trace); DefaultUserSessionEventsService sut = CreateSut(); - await sut.HandleUserSessionLogout(new UserSessionEventContext { SessionId = "session", SubjectId = "subject" }); + await sut.HandleUserSessionLogout(new EndUserSessionEventContext { SessionId = "session", SubjectId = "subject" }); Mock.Get(telemetry) .Verify(t => t.Trace( @@ -416,7 +438,7 @@ public async Task HandleUserSessionExpiry_WhenCalled_ShouldInitiateTelemetryTrac .Returns(trace); DefaultUserSessionEventsService sut = CreateSut(); - await sut.HandleUserSessionExpiry(new UserSessionEventContext { SessionId = "session", SubjectId = "subject" }); + await sut.HandleUserSessionExpiry(new EndUserSessionEventContext { SessionId = "session", SubjectId = "subject" }); Mock.Get(telemetry) .Verify(t => t.Trace( @@ -425,13 +447,305 @@ public async Task HandleUserSessionExpiry_WhenCalled_ShouldInitiateTelemetryTrac .Verify(t => t.Dispose(), Times.Once); } + [Fact] + private async Task ValidateRefreshTokenAsync_WhenAuthTicketStoreRegistered_ShouldReturnTrue() + { + serverSessionTicketStore = null!; + ValidateUserSessionEventContext testCtx = new() + { + SubjectId = "fakeSubjectId", + SessionId = "fakeSessionId", + Client = new Client + { + CoordinateLifetimeWithUserSession = true + } + }; + + DefaultUserSessionEventsService sut = CreateSut(); + bool actual = await sut.ValidateSession(testCtx); + + actual.Should().BeTrue(); + } + + [Fact] + private async Task ValidateRefreshTokenAsync_WhenNoSessionStoreRegistered_ShouldReturnTrue() + { + identityServerServerSideSessionStore = null!; + ValidateUserSessionEventContext testCtx = new() + { + SubjectId = "fakeSubjectId", + SessionId = "fakeSessionId", + Client = new Client + { + CoordinateLifetimeWithUserSession = true + } + }; + + DefaultUserSessionEventsService sut = CreateSut(); + bool actual = await sut.ValidateSession(testCtx); + + actual.Should().BeTrue(); + } + + [Theory] + [InlineData(false, null)] + [InlineData(true, false)] + private async Task ValidateRefreshTokenAsync_CoordinationDisabled_ShouldCallDecoratedAndReturnResponse(bool authOpt, bool? clientVal) + { + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession = authOpt; + ValidateUserSessionEventContext testCtx = new() + { + SubjectId = "fakeSubjectId", + SessionId = "fakeSessionId", + Client = new Client + { + CoordinateLifetimeWithUserSession = clientVal + } + }; + + DefaultUserSessionEventsService sut = CreateSut(); + bool actual = await sut.ValidateSession(testCtx); + + actual.Should().BeTrue(); + } + + [Theory] + [InlineData(true, null)] + [InlineData(false, true)] + private async Task ValidateRefreshTokenAsync_CoordinationEnabledWithoutValidSessions_ShouldReturnFalse(bool authOpt, bool? clientVal) + { + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession = authOpt; + ValidateUserSessionEventContext testCtx = new() + { + SubjectId = "fakeSubjectId", + SessionId = "fakeSessionId", + Client = new Client + { + CoordinateLifetimeWithUserSession = clientVal + } + }; + + DefaultUserSessionEventsService sut = CreateSut(); + bool actual = await sut.ValidateSession(testCtx); + + actual.Should().BeFalse(); + } + + [Theory] + [InlineData(false, null)] + [InlineData(false, true)] + [InlineData(true, false)] + private async Task ValidateRefreshTokenAsync_CoordinationEnabledWithValidSessions_AndIsNonPersistantOrDoesntAllowRefresh_ShouldUpdateSession(bool isPersistent, bool? allowRefresh) + { + const string fakKey = "sessionKey"; + const string fakeScheme = "authScheme"; + const string fakeDisplayName = "Fake User"; + const string fakeSessionId = "sessionId"; + const string fakeSubjectId = "subjectId"; + + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession = true; + ValidateUserSessionEventContext testCtx = new() + { + SubjectId = fakeSubjectId, + SessionId = fakeSessionId, + Client = new Client + { + CoordinateLifetimeWithUserSession = null, + } + }; + + DateTime issued = fakeNow.AddDays(-10); + DateTime expires = fakeNow.AddDays(19); + IdentityServerServerSideSessions fakeSession = FakeSession(fakKey, fakeScheme, fakeSessionId, fakeSubjectId, fakeDisplayName, + created: issued, renewed: issued, expires: expires); + AuthenticationTicket fakeAuthTicket = GenerateAuthenticationTicket(fakeScheme, fakeSubjectId, fakeSessionId, fakeDisplayName, + isPersistent: isPersistent, allowRefresh: allowRefresh, issuedUtc: issued, expiresUtc: expires); + + Mock.Get(serverSessionTicketStore) + .Setup(x => x.FilterServerAuthenticationTickets(fakeSubjectId, fakeSessionId)) + .ReturnsAsync([ + new AuthenticationTicketFilterResult { Session = fakeSession, AuthTicket = fakeAuthTicket }, + ]); + + IdentityServerServerSideSessions? updatedSession = null; + Mock.Get(identityServerServerSideSessionStore) + .Setup(x => x.UpdateSession(It.IsAny())) + .Callback(x => updatedSession = x); + + DefaultUserSessionEventsService sut = CreateSut(); + bool actual = await sut.ValidateSession(testCtx); + + actual.Should().BeTrue(); + + updatedSession.Should().BeEquivalentTo(fakeSession, opt => opt + .Excluding(y => y.Renewed) + .Excluding(y => y.Expires)); + + updatedSession.Renewed.Should().Be(fakeNow); + updatedSession.Expires.Should().Be(fakeNow.AddDays(29)); + + Mock.Get(identityServerServerSideSessionStore) + .Verify(x => x.UpdateSession(It.IsAny())); + } + + [Theory] + [InlineData(true, null)] + [InlineData(true, true)] + private async Task ValidateRefreshTokenAsync_CoordinationEnabledWithValidSessions_AndNoSlidingExpiration_AndIsPersistantAndAllowRefresh_ShouldUpdateSession(bool isPersistent, bool? allowRefresh) + { + const string fakKey = "sessionKey"; + const string fakeScheme = "authScheme"; + const string fakeDisplayName = "Fake User"; + const string fakeSessionId = "sessionId"; + const string fakeSubjectId = "subjectId"; + + idsOptions.Authentication.CookieSlidingExpiration = false; + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession = true; + ValidateUserSessionEventContext testCtx = new() + { + SubjectId = fakeSubjectId, + SessionId = fakeSessionId, + Client = new Client + { + CoordinateLifetimeWithUserSession = null, + } + }; + + DateTime issued = fakeNow.AddDays(-10); + DateTime expires = fakeNow.AddDays(19); + IdentityServerServerSideSessions fakeSession = FakeSession(fakKey, fakeScheme, fakeSessionId, fakeSubjectId, fakeDisplayName, + created: issued, renewed: issued, expires: expires); + AuthenticationTicket fakeAuthTicket = GenerateAuthenticationTicket(fakeScheme, fakeSubjectId, fakeSessionId, fakeDisplayName, + isPersistent: isPersistent, allowRefresh: allowRefresh, issuedUtc: issued, expiresUtc: expires); + + Mock.Get(serverSessionTicketStore) + .Setup(x => x.FilterServerAuthenticationTickets(fakeSubjectId, fakeSessionId)) + .ReturnsAsync([ + new AuthenticationTicketFilterResult { Session = fakeSession, AuthTicket = fakeAuthTicket }, + ]); + + IdentityServerServerSideSessions? updatedSession = null; + Mock.Get(identityServerServerSideSessionStore) + .Setup(x => x.UpdateSession(It.IsAny())) + .Callback(x => updatedSession = x); + + DefaultUserSessionEventsService sut = CreateSut(); + bool actual = await sut.ValidateSession(testCtx); + + actual.Should().BeTrue(); + + updatedSession.Should().BeEquivalentTo(fakeSession, opt => opt + .Excluding(y => y.Renewed) + .Excluding(y => y.Expires)); + + updatedSession.Renewed.Should().Be(fakeNow); + updatedSession.Expires.Should().Be(fakeNow.AddDays(29)); + + Mock.Get(identityServerServerSideSessionStore) + .Verify(x => x.UpdateSession(It.IsAny())); + } + + [Theory] + [InlineData(true, null)] + [InlineData(true, true)] + private async Task ValidateRefreshTokenAsync_CoordinationEnabledWithValidSessions_AndSlidingExpiration_AndIsPersistantAndAllowRefresh_ShouldRenewTicketAndTriggerCookieRefresh(bool isPersistent, bool? allowRefresh) + { + const string fakKey = "sessionKey"; + const string fakeScheme = "authScheme"; + const string fakeDisplayName = "Fake User"; + const string fakeSessionId = "sessionId"; + const string fakeSubjectId = "subjectId"; + + idsOptions.Authentication.CookieSlidingExpiration = true; + idsOptions.Authentication.CoordinateClientLifetimesWithUserSession = true; + ValidateUserSessionEventContext testCtx = new() + { + SubjectId = fakeSubjectId, + SessionId = fakeSessionId, + Client = new Client + { + CoordinateLifetimeWithUserSession = null, + } + }; + + DateTime issued = fakeNow.AddDays(-10); + DateTime expires = fakeNow.AddDays(19); + IdentityServerServerSideSessions fakeSession = FakeSession(fakKey, fakeScheme, fakeSessionId, fakeSubjectId, fakeDisplayName, + created: issued, renewed: issued, expires: expires); + AuthenticationTicket fakeAuthTicket = GenerateAuthenticationTicket(fakeScheme, fakeSubjectId, fakeSessionId, fakeDisplayName, + isPersistent: isPersistent, allowRefresh: allowRefresh, issuedUtc: issued, expiresUtc: expires); + + Mock.Get(serverSessionTicketStore) + .Setup(x => x.FilterServerAuthenticationTickets(fakeSubjectId, fakeSessionId)) + .ReturnsAsync([ + new AuthenticationTicketFilterResult { Session = fakeSession, AuthTicket = fakeAuthTicket }, + ]); + + AuthenticationTicket? updatedTicket = null; + Mock.Get(serverSessionTicketStore) + .Setup(x => x.RenewAsync(It.IsAny(), It.IsAny())) + .Callback((k, x) => updatedTicket = x); + + DefaultUserSessionEventsService sut = CreateSut(); + bool actual = await sut.ValidateSession(testCtx); + + actual.Should().BeTrue(); + + updatedTicket.Should().BeEquivalentTo(fakeAuthTicket); + updatedTicket.Properties.IssuedUtc.Should().Be(fakeNow); + updatedTicket.Properties.ExpiresUtc.Should().Be(fakeNow.AddDays(29)); + updatedTicket.Properties.GetString(IdentityServerConstants.ForceCookieRefresh).Should().BeEmpty(); + + Mock.Get(serverSessionTicketStore) + .Verify(x => x.RenewAsync(It.IsAny(), It.IsAny())); + } + private void SetupClientStore(IEnumerable clients) { - foreach (var client in clients) + foreach (Client client in clients) { Mock.Get(clientStore) .Setup(x => x.FindClientByIdAsync(client.ClientId)) .ReturnsAsync(client); } } + + private AuthenticationTicket GenerateAuthenticationTicket(string authScheme, string? subjectId, string? sessionId, + string? displayName = null, DateTimeOffset? issuedUtc = null, DateTimeOffset? expiresUtc = null, + bool isPersistent = false, bool? allowRefresh = false) + { + IdentityServerUser user = new(subjectId); + AuthenticationProperties properties = new(); + + properties.SetSessionId(sessionId); + + user.DisplayName = displayName; + + properties.IssuedUtc = issuedUtc; + properties.ExpiresUtc = expiresUtc; + properties.IsPersistent = isPersistent; + properties.AllowRefresh = allowRefresh; + + return new AuthenticationTicket(user.CreatePrincipal(), properties, authScheme); + } + + 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 From 82d10d41159122bcb08aa3f6de3afde262d76199 Mon Sep 17 00:00:00 2001 From: James Britton Date: Thu, 27 Aug 2026 14:24:09 +0100 Subject: [PATCH 26/59] feat: add event handler for checking for ForceCookieRefresh flag --- .../ConfigureInternalCookieOptions.cs | 31 ++--- .../src/Events/CookieAuthenticationEvents.cs | 33 +++++ .../Events/CookieAuthenticationEventsTests.cs | 128 ++++++++++++++++++ 3 files changed, 175 insertions(+), 17 deletions(-) create mode 100644 src/Open.IdentityServer/src/Events/CookieAuthenticationEvents.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Events/CookieAuthenticationEventsTests.cs diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/ConfigureInternalCookieOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/ConfigureInternalCookieOptions.cs index 502cc8e81..86bd053f8 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/ConfigureInternalCookieOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/ConfigureInternalCookieOptions.cs @@ -1,4 +1,5 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. +// Modified by Rock Solid Knowledge Ltd. Copyright in modifications 2026, Rock Solid Knowledge Ltd. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. @@ -6,18 +7,13 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using CookieAuthenticationEvents = Open.IdentityServer.Events.CookieAuthenticationEvents; namespace Open.IdentityServer.Configuration; -internal class ConfigureInternalCookieOptions : IConfigureNamedOptions +internal class ConfigureInternalCookieOptions(IdentityServerOptions idsrv) + : IConfigureNamedOptions { - private readonly IdentityServerOptions _idsrv; - - public ConfigureInternalCookieOptions(IdentityServerOptions idsrv) - { - _idsrv = idsrv; - } - public void Configure(CookieAuthenticationOptions options) { } @@ -26,20 +22,21 @@ public void Configure(string name, CookieAuthenticationOptions options) { if (name == IdentityServerConstants.DefaultCookieAuthenticationScheme) { - options.SlidingExpiration = _idsrv.Authentication.CookieSlidingExpiration; - options.ExpireTimeSpan = _idsrv.Authentication.CookieLifetime; + options.SlidingExpiration = idsrv.Authentication.CookieSlidingExpiration; + options.ExpireTimeSpan = idsrv.Authentication.CookieLifetime; options.Cookie.Name = IdentityServerConstants.DefaultCookieAuthenticationScheme; options.Cookie.IsEssential = true; - options.Cookie.SameSite = _idsrv.Authentication.CookieSameSiteMode; + options.Cookie.SameSite = idsrv.Authentication.CookieSameSiteMode; - options.LoginPath = ExtractLocalUrl(_idsrv.UserInteraction.LoginUrl); - options.LogoutPath = ExtractLocalUrl(_idsrv.UserInteraction.LogoutUrl); - if (_idsrv.UserInteraction.LoginReturnUrlParameter != null) + options.LoginPath = ExtractLocalUrl(idsrv.UserInteraction.LoginUrl); + options.LogoutPath = ExtractLocalUrl(idsrv.UserInteraction.LogoutUrl); + if (idsrv.UserInteraction.LoginReturnUrlParameter != null) { - options.ReturnUrlParameter = _idsrv.UserInteraction.LoginReturnUrlParameter; + options.ReturnUrlParameter = idsrv.UserInteraction.LoginReturnUrlParameter; } - // options.Events.OnCheckSlidingExpiration = TODO implement to use a flag in the auth ticket properties to decide if the cookie needs refreshing + options.Events.OnCheckSlidingExpiration = context => CookieAuthenticationEvents + .ServerSessionOnCheckSlidingExpiration(context, options.Events.OnCheckSlidingExpiration); } if (name == IdentityServerConstants.ExternalCookieAuthenticationScheme) @@ -52,7 +49,7 @@ public void Configure(string name, CookieAuthenticationOptions options) // so we need to make those cookies issued without same-site, thus the browser will // hold onto them and send on the next redirect to the callback page. // see: https://brockallen.com/2019/01/11/same-site-cookies-asp-net-core-and-external-authentication-providers/ - options.Cookie.SameSite = _idsrv.Authentication.CookieSameSiteMode; + options.Cookie.SameSite = idsrv.Authentication.CookieSameSiteMode; } } diff --git a/src/Open.IdentityServer/src/Events/CookieAuthenticationEvents.cs b/src/Open.IdentityServer/src/Events/CookieAuthenticationEvents.cs new file mode 100644 index 000000000..327523552 --- /dev/null +++ b/src/Open.IdentityServer/src/Events/CookieAuthenticationEvents.cs @@ -0,0 +1,33 @@ +// 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.Threading.Tasks; +using Microsoft.AspNetCore.Authentication.Cookies; + +namespace Open.IdentityServer.Events; + +/// +/// A class containing cookie authentication handler events +/// +public static class CookieAuthenticationEvents +{ + /// + /// Server-side sessions cookie on check sliding expiration + /// + public static readonly Func, Task> ServerSessionOnCheckSlidingExpiration = (ctx, original) => + { + original.Invoke(ctx); + + if (ctx.Properties.GetString(IdentityServerConstants.ForceCookieRefresh) != null && + (ctx.Properties.ExpiresUtc == null || TimeProvider.System.GetUtcNow() < ctx.Properties.ExpiresUtc)) + { + ctx.ShouldRenew = true; + ctx.Properties.SetString(IdentityServerConstants.ForceCookieRefresh, null); + } + + return Task.CompletedTask; + }; +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Events/CookieAuthenticationEventsTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Events/CookieAuthenticationEventsTests.cs new file mode 100644 index 000000000..3a24b02fa --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Events/CookieAuthenticationEventsTests.cs @@ -0,0 +1,128 @@ +// 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.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.UnitTests.Common; +using Xunit; +using CookieAuthenticationEvents = Open.IdentityServer.Events.CookieAuthenticationEvents; + +namespace Open.IdentityServer.UnitTests.Events; + +public class CookieAuthenticationEventsTests +{ + private string fakeAuthScheme = "FakeScheme"; + private string fakeSubjectId = "subject"; + private string fakeSessionId = "session"; + private readonly CookieSlidingExpirationContext fakeContext; + AuthenticationProperties authProperties = new(); + + public CookieAuthenticationEventsTests() + { + IdentityServerUser user = new(fakeSubjectId); + + authProperties.SetSessionId(fakeSessionId); + + user.DisplayName = "John Smith"; + + fakeContext = new CookieSlidingExpirationContext( + new MockHttpContextAccessor().HttpContext!, + new AuthenticationScheme(fakeAuthScheme, null, typeof(MockAuthenticationHandler)), + new CookieAuthenticationOptions(), + new AuthenticationTicket(user.CreatePrincipal(), authProperties, fakeAuthScheme), + TimeSpan.FromDays(1), + TimeSpan.FromDays(1)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void OnCheckSlidingExpiration_WhenNoFlagSet_ShouldCallOriginal_AndNotChangeShouldRenew(bool shouldRenew) + { + fakeContext.ShouldRenew = shouldRenew; + bool originalShouldRenew = fakeContext.ShouldRenew; + + bool originalEventCalled = false; + Task FakeOriginalEvent(CookieSlidingExpirationContext _) + { + originalEventCalled = true; + return Task.CompletedTask; + } + + CookieAuthenticationEvents.ServerSessionOnCheckSlidingExpiration(fakeContext, FakeOriginalEvent); + + originalEventCalled.Should().BeTrue(); + fakeContext.ShouldRenew.Should().Be(originalShouldRenew); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void OnCheckSlidingExpiration_WhenFlagSet_AndExpired_ShouldCallOriginal_AndNotChangeShouldRenew(bool shouldRenew) + { + fakeContext.ShouldRenew = shouldRenew; + bool originalShouldRenew = fakeContext.ShouldRenew; + + authProperties.SetString(IdentityServerConstants.ForceCookieRefresh, string.Empty); + authProperties.IssuedUtc = TimeProvider.System.GetUtcNow().AddHours(-8); + authProperties.ExpiresUtc = TimeProvider.System.GetUtcNow().AddHours(-2); + + bool originalEventCalled = false; + Task FakeOriginalEvent(CookieSlidingExpirationContext _) + { + originalEventCalled = true; + return Task.CompletedTask; + } + + CookieAuthenticationEvents.ServerSessionOnCheckSlidingExpiration(fakeContext, FakeOriginalEvent); + + originalEventCalled.Should().BeTrue(); + fakeContext.ShouldRenew.Should().Be(originalShouldRenew); + } + + [Fact] + public void OnCheckSlidingExpiration_WhenFlagSet_AndNotExpired_ShouldCallOriginal_AndShouldRenewShouldBeTrue() + { + authProperties.SetString(IdentityServerConstants.ForceCookieRefresh, string.Empty); + authProperties.IssuedUtc = TimeProvider.System.GetUtcNow().AddHours(-3); + authProperties.ExpiresUtc = TimeProvider.System.GetUtcNow().AddHours(3); + + bool originalEventCalled = false; + Task FakeOriginalEvent(CookieSlidingExpirationContext _) + { + originalEventCalled = true; + return Task.CompletedTask; + } + + CookieAuthenticationEvents.ServerSessionOnCheckSlidingExpiration(fakeContext, FakeOriginalEvent); + + originalEventCalled.Should().BeTrue(); + fakeContext.ShouldRenew.Should().BeTrue(); + } + + [Fact] + public void OnCheckSlidingExpiration_WhenFlagSet_AndHasNoExpiry_ShouldCallOriginal_AndShouldRenewShouldBeTrue() + { + authProperties.SetString(IdentityServerConstants.ForceCookieRefresh, string.Empty); + authProperties.IssuedUtc = TimeProvider.System.GetUtcNow().AddHours(-3); + authProperties.ExpiresUtc = null; + + bool originalEventCalled = false; + Task FakeOriginalEvent(CookieSlidingExpirationContext _) + { + originalEventCalled = true; + return Task.CompletedTask; + } + + CookieAuthenticationEvents.ServerSessionOnCheckSlidingExpiration(fakeContext, FakeOriginalEvent); + + originalEventCalled.Should().BeTrue(); + fakeContext.ShouldRenew.Should().BeTrue(); + } +} \ No newline at end of file From 00a8d9c6f912aea6d0143f5d85fb846c7632021b Mon Sep 17 00:00:00 2001 From: James Britton Date: Thu, 27 Aug 2026 16:20:35 +0100 Subject: [PATCH 27/59] feat: working on triggering session validation from token validators --- ...ultServerSideSessionRefreshTokenService.cs | 66 ++++++ .../DefaultServerSideSessionTokenValidator.cs | 67 ++++++ .../ServerSideSessionTokenValidator.cs | 37 --- ...rverSideSessionRefreshTokenServiceTests.cs | 207 +++++++++++++++++ ...ultServerSideSessionTokenValidatorTests.cs | 218 ++++++++++++++++++ 5 files changed, 558 insertions(+), 37 deletions(-) create mode 100644 src/Open.IdentityServer/src/Validation/Default/DefaultServerSideSessionRefreshTokenService.cs create mode 100644 src/Open.IdentityServer/src/Validation/Default/DefaultServerSideSessionTokenValidator.cs delete mode 100644 src/Open.IdentityServer/src/Validation/Default/ServerSideSessionTokenValidator.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/Default/DefaultServerSideSessionRefreshTokenServiceTests.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/Default/DefaultServerSideSessionTokenValidatorTests.cs diff --git a/src/Open.IdentityServer/src/Validation/Default/DefaultServerSideSessionRefreshTokenService.cs b/src/Open.IdentityServer/src/Validation/Default/DefaultServerSideSessionRefreshTokenService.cs new file mode 100644 index 000000000..47d7ba1cf --- /dev/null +++ b/src/Open.IdentityServer/src/Validation/Default/DefaultServerSideSessionRefreshTokenService.cs @@ -0,0 +1,66 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +#nullable enable + +using System.Threading.Tasks; +using Open.IdentityServer.Models; +using Open.IdentityServer.Services; + +namespace Open.IdentityServer.Validation; + +/// +/// +/// +/// +/// +/// +public class DefaultServerSideSessionRefreshTokenService( + IRefreshTokenService decoratedService, + IUserSessionEventsService userSessionEventsService, + ITelemetryService telemetry): IRefreshTokenService +{ + /// + /// + /// + /// + /// + /// + public async Task ValidateRefreshTokenAsync(string token, Client client) + { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Validation, this); + + TokenValidationResult? validatedRefreshToken = await decoratedService.ValidateRefreshTokenAsync(token, client); + + if (validatedRefreshToken?.IsError ?? true) + { + return validatedRefreshToken; + } + + bool sessionValid = await userSessionEventsService.ValidateSession(new ValidateUserSessionEventContext + { + SessionId = validatedRefreshToken.RefreshToken.SessionId, + SubjectId = validatedRefreshToken.RefreshToken.SubjectId, + Client = validatedRefreshToken.Client, + }); + + if (!sessionValid) + { + return new TokenValidationResult + { + IsError = true, + Error = OidcConstants.ProtectedResourceErrors.InvalidToken, + }; + } + + return validatedRefreshToken; + } + + /// + public Task CreateRefreshTokenAsync(RefreshTokenCreationRequest request) => + decoratedService.CreateRefreshTokenAsync(request); + + /// + public Task UpdateRefreshTokenAsync(string handle, RefreshToken refreshToken, Client client) => + decoratedService.UpdateRefreshTokenAsync(handle, refreshToken, client); +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Validation/Default/DefaultServerSideSessionTokenValidator.cs b/src/Open.IdentityServer/src/Validation/Default/DefaultServerSideSessionTokenValidator.cs new file mode 100644 index 000000000..782d391e9 --- /dev/null +++ b/src/Open.IdentityServer/src/Validation/Default/DefaultServerSideSessionTokenValidator.cs @@ -0,0 +1,67 @@ +// 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.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Open.IdentityServer.Models; +using Open.IdentityServer.Services; + +namespace Open.IdentityServer.Validation; + +/// +/// +/// +/// +/// +/// +public class DefaultServerSideSessionTokenValidator( + ITokenValidator decoratedService, + IUserSessionEventsService userSessionEventsService, + ITelemetryService telemetry) : ITokenValidator +{ + /// + /// + /// + /// + /// + /// + public async Task ValidateAccessTokenAsync(string token, string? expectedScope = null) + { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Validation, this); + + TokenValidationResult? validatedAccessToken = await decoratedService.ValidateAccessTokenAsync(token, expectedScope); + + Claim? sid = validatedAccessToken?.Claims.FirstOrDefault(x => x.Type == JwtClaimTypes.SessionId); + Claim? sub = validatedAccessToken?.Claims.FirstOrDefault(x => x.Type == JwtClaimTypes.Subject); + + if (validatedAccessToken == null || sid == null || sub == null) + { + return validatedAccessToken; + } + + bool sessionValid = await userSessionEventsService.ValidateSession(new ValidateUserSessionEventContext + { + SessionId = sid.Value, + SubjectId = sub.Value, + Client = validatedAccessToken.Client, + }); + + if (!sessionValid) + { + return new TokenValidationResult + { + IsError = true, + Error = OidcConstants.ProtectedResourceErrors.InvalidToken, + }; + } + + return validatedAccessToken; + } + + /// + public Task ValidateIdentityTokenAsync(string token, string? clientId = null, bool validateLifetime = true) => + decoratedService.ValidateIdentityTokenAsync(token, clientId, validateLifetime); +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Validation/Default/ServerSideSessionTokenValidator.cs b/src/Open.IdentityServer/src/Validation/Default/ServerSideSessionTokenValidator.cs deleted file mode 100644 index a3613db8a..000000000 --- a/src/Open.IdentityServer/src/Validation/Default/ServerSideSessionTokenValidator.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authentication.Cookies; -using Open.IdentityServer.Models; -using Open.IdentityServer.Services; - -namespace Open.IdentityServer.Validation; - -/// -/// -/// -/// -/// -public class DefaultServerSideSessionRefreshTokenService( - IRefreshTokenService decoratedService, - ITicketStore ticketStore): IRefreshTokenService -{ - /// - /// - /// - /// - /// - /// - public async Task ValidateRefreshTokenAsync(string token, Client client) - { - var validatedRefreshToken = await decoratedService.ValidateRefreshTokenAsync(token, client); - - throw new System.NotImplementedException(); - } - - /// - public Task CreateRefreshTokenAsync(RefreshTokenCreationRequest request) => - decoratedService.CreateRefreshTokenAsync(request); - - /// - public Task UpdateRefreshTokenAsync(string handle, RefreshToken refreshToken, Client client) => - decoratedService.UpdateRefreshTokenAsync(handle, refreshToken, client); -} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/Default/DefaultServerSideSessionRefreshTokenServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/Default/DefaultServerSideSessionRefreshTokenServiceTests.cs new file mode 100644 index 000000000..fe610f481 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/Default/DefaultServerSideSessionRefreshTokenServiceTests.cs @@ -0,0 +1,207 @@ +// 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 System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using AwesomeAssertions; +using Moq; +using Open.IdentityServer.Models; +using Open.IdentityServer.Services; +using Open.IdentityServer.Validation; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Validation.Default; + +public class DefaultServerSideSessionRefreshTokenServiceTests +{ + private IRefreshTokenService decoratedService = Mock.Of(); + private IUserSessionEventsService userSessionEventsService = Mock.Of(); + private ITelemetryService telemetry = Mock.Of(); + + private DefaultServerSideSessionRefreshTokenService CreateSut() => + new(decoratedService, userSessionEventsService, telemetry); + + [Fact] + public async Task ValidateRefreshTokenAsync_WhenDecoratedResultIsInvalid_ShouldNotValidateUserSession() + { + const string fakeToken = "fake_token"; + Client fakeClient = new Client(); + + var fakeResult = new TokenValidationResult + { + IsError = true, + Error = "fake_error" + }; + + Mock.Get(decoratedService) + .Setup(x => x.ValidateRefreshTokenAsync(fakeToken, fakeClient)) + .ReturnsAsync(fakeResult); + + DefaultServerSideSessionRefreshTokenService sut = CreateSut(); + + TokenValidationResult actual = await sut.ValidateRefreshTokenAsync(fakeToken, fakeClient); + + actual.Should().Be(fakeResult); + + Mock.Get(userSessionEventsService) + .Verify(x => x.ValidateSession(It.IsAny()), Times.Never); + } + + [Fact] + public async Task ValidateRefreshTokenAsync_WhenDecoratedResultIsValid_AndValidateUserSessionUnsuccessful_ShouldReturnErrorResult() + { + const string fakeToken = "fake_token"; + const string fakeSubject = "fakeSubject"; + const string fakeSession = "fakeSession"; + Client fakeClient = new Client(); + + var fakeResult = new TokenValidationResult + { + RefreshToken = new RefreshToken { Subject = new IdentityServerUser(fakeSubject).CreatePrincipal(), SessionId = fakeSession }, + Client = fakeClient, IsError = false, + }; + + Mock.Get(decoratedService) + .Setup(x => x.ValidateRefreshTokenAsync(fakeToken, fakeClient)) + .ReturnsAsync(fakeResult); + + Mock.Get(userSessionEventsService) + .Setup(x => x.ValidateSession(It.Is(ctx => + ctx.SessionId == fakeSession && ctx.SubjectId == fakeSubject && ctx.Client == fakeClient))) + .ReturnsAsync(false); + + DefaultServerSideSessionRefreshTokenService sut = CreateSut(); + + TokenValidationResult actual = await sut.ValidateRefreshTokenAsync(fakeToken, fakeClient); + + actual.IsError.Should().BeTrue(); + actual.Error.Should().Be(OidcConstants.ProtectedResourceErrors.InvalidToken); + + Mock.Get(userSessionEventsService) + .Verify(x => x.ValidateSession(It.Is(ctx => + ctx.SessionId == fakeSession && ctx.SubjectId == fakeSubject && ctx.Client == fakeClient))); + } + + [Fact] + public async Task ValidateRefreshTokenAsync_WhenDecoratedResultIsValid_AndValidateUserSessionSuccessful_ShouldReturnDecoratedServiceResult() + { + const string fakeToken = "fake_token"; + const string fakeSubject = "fakeSubject"; + const string fakeSession = "fakeSession"; + Client fakeClient = new Client(); + + var fakeResult = new TokenValidationResult + { + RefreshToken = new RefreshToken { Subject = new IdentityServerUser(fakeSubject).CreatePrincipal(), SessionId = fakeSession }, + Client = fakeClient, IsError = false, + }; + Mock.Get(decoratedService) + .Setup(x => x.ValidateRefreshTokenAsync(fakeToken, fakeClient)) + .ReturnsAsync(fakeResult); + + Mock.Get(userSessionEventsService) + .Setup(x => x.ValidateSession(It.Is(ctx => + ctx.SessionId == fakeSession && ctx.SubjectId == fakeSubject && ctx.Client == fakeClient))) + .ReturnsAsync(true); + + DefaultServerSideSessionRefreshTokenService sut = CreateSut(); + + TokenValidationResult actual = await sut.ValidateRefreshTokenAsync(fakeToken, fakeClient); + + actual.Should().Be(fakeResult); + + Mock.Get(userSessionEventsService) + .Verify(x => x.ValidateSession(It.Is(ctx => + ctx.SessionId == fakeSession && ctx.SubjectId == fakeSubject && ctx.Client == fakeClient))); + } + + [Fact] + public async Task CreateRefreshTokenAsync_ShouldJustUseDecoratedService() + { + RefreshTokenCreationRequest fakeRequest = new RefreshTokenCreationRequest + { + Subject = null, AccessToken = null, Client = null, AuthorisedScopes = null, + AuthorisedResourceIndicators = null, RequestedResourceIndicator = null + }; + string fakeToken = "fake_refresh_token"; + + DefaultServerSideSessionRefreshTokenService sut = CreateSut(); + + Mock.Get(decoratedService) + .Setup(x => x.CreateRefreshTokenAsync(fakeRequest)) + .ReturnsAsync(fakeToken); + + string actual = await sut.CreateRefreshTokenAsync(fakeRequest); + + Mock.Get(decoratedService) + .Verify(x => x.CreateRefreshTokenAsync(fakeRequest)); + + actual.Should().Be(fakeToken); + } + + [Fact] + public async Task UpdateRefreshTokenAsync_ShouldJustUseDecoratedService() + { + string fakeHandle = "fakeHandle"; + RefreshToken fakeRefreshToken = new RefreshToken(); + Client fakeClient = new Client(); + string fakeToken = "fake_refresh_token"; + + DefaultServerSideSessionRefreshTokenService sut = CreateSut(); + + Mock.Get(decoratedService) + .Setup(x => x.UpdateRefreshTokenAsync(fakeHandle, fakeRefreshToken, fakeClient)) + .ReturnsAsync(fakeToken); + + string actual = await sut.UpdateRefreshTokenAsync(fakeHandle, fakeRefreshToken, fakeClient); + + Mock.Get(decoratedService) + .Verify(x => x.UpdateRefreshTokenAsync(fakeHandle, fakeRefreshToken, fakeClient)); + + actual.Should().Be(fakeToken); + } + + [Fact] + public async Task PublicMethods_WithCustomisedLogic_WhenCalled_ShouldTelemetryTrace() + { + string fakeHandle = "fakeHandle"; + Client fakeClient = new Client(); + string fakeToken = "fake_refresh_token"; + + List<(Func actMethod, string traceMethodName)> methods + = [ + (store => store.ValidateRefreshTokenAsync(fakeToken, fakeClient), "ValidateRefreshTokenAsync"), + ]; + + DefaultServerSideSessionRefreshTokenService 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.Validation, sut, method.traceMethodName), Times.Once); + Mock.Get(trace).Verify(t => t.Dispose(), Times.Once); + } + + // Assert all methods covered + // typeof(DefaultServerSideSessionRefreshTokenService).GetMethods() + // .Where(m => m is { IsPublic: true, IsStatic: false, IsSpecialName: false }) + // .Where(m => m.DeclaringType == typeof(DefaultServerSideSessionRefreshTokenService)) + // .Select(m => m.Name) + // .Distinct() + // .Should().BeEquivalentTo(methods.Select(m => m.traceMethodName)); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/Default/DefaultServerSideSessionTokenValidatorTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/Default/DefaultServerSideSessionTokenValidatorTests.cs new file mode 100644 index 000000000..43bac9657 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/Default/DefaultServerSideSessionTokenValidatorTests.cs @@ -0,0 +1,218 @@ +// 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 System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using AwesomeAssertions; +using Moq; +using Open.IdentityServer.Models; +using Open.IdentityServer.Services; +using Open.IdentityServer.Validation; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Validation.Default; + +public class DefaultServerSideSessionTokenValidatorTests +{ + private ITokenValidator decoratedService = Mock.Of(); + private IUserSessionEventsService userSessionEventsService = Mock.Of(); + private ITelemetryService telemetry = Mock.Of(); + + private DefaultServerSideSessionTokenValidator CreateSut() => + new(decoratedService, userSessionEventsService, telemetry); + + + [Fact] + public async Task ValidateAccessTokenAsync_WhenDecoratedResultIsInvalid_ShouldNotValidateUserSession() + { + const string fakeToken = "fake_token"; + + var fakeResult = new TokenValidationResult { Claims = [], IsError = true, Error = "fake_error" }; + + Mock.Get(decoratedService) + .Setup(x => x.ValidateAccessTokenAsync(fakeToken, It.IsAny())) + .ReturnsAsync(fakeResult); + + DefaultServerSideSessionTokenValidator sut = CreateSut(); + + TokenValidationResult? actual = await sut.ValidateAccessTokenAsync(fakeToken); + + actual.Should().NotBeNull(); + actual.Should().Be(fakeResult); + + Mock.Get(userSessionEventsService) + .Verify(x => x.ValidateSession(It.IsAny()), Times.Never); + } + + [Fact] + public async Task + ValidateAccessTokenAsync_WhenTokenDoesntContainsSubjectSessionClaims_ShouldNotValidateUserSession() + { + const string fakeToken = "fake_token"; + + var fakeResult = new TokenValidationResult { Claims = [], IsError = false, }; + + Mock.Get(decoratedService) + .Setup(x => x.ValidateAccessTokenAsync(fakeToken, It.IsAny())) + .ReturnsAsync(fakeResult); + + DefaultServerSideSessionTokenValidator sut = CreateSut(); + + TokenValidationResult? actual = await sut.ValidateAccessTokenAsync(fakeToken); + + actual.Should().NotBeNull(); + actual.Should().Be(fakeResult); + + Mock.Get(userSessionEventsService) + .Verify(x => x.ValidateSession(It.IsAny()), Times.Never); + } + + [Fact] + public async Task + ValidateAccessTokenAsync_WhenTokenContainsSubjectSessionClaims_AndSessionInValid_ShouldReturnInavlidSessionError() + { + const string fakeToken = "fake_token"; + const string fakeSession = "fakeSession"; + const string fakeSubject = "fakeSubject"; + Client fakeClient = new Client(); + + var fakeResult = new TokenValidationResult + { + Claims = + [ + new Claim(JwtClaimTypes.SessionId, fakeSession), + new Claim(JwtClaimTypes.Subject, fakeSubject), + ], + Client = fakeClient, IsError = false, + }; + + Mock.Get(decoratedService) + .Setup(x => x.ValidateAccessTokenAsync(fakeToken, It.IsAny())) + .ReturnsAsync(fakeResult); + + Mock.Get(userSessionEventsService) + .Setup(x => x.ValidateSession(It.Is(ctx => + ctx.SessionId == fakeSession && ctx.SubjectId == fakeSubject && ctx.Client == fakeClient))) + .ReturnsAsync(false); + + DefaultServerSideSessionTokenValidator sut = CreateSut(); + + TokenValidationResult? actual = await sut.ValidateAccessTokenAsync(fakeToken); + + actual.Should().NotBeNull(); + actual.IsError.Should().BeTrue(); + actual.Error.Should().Be(OidcConstants.ProtectedResourceErrors.InvalidToken); + + Mock.Get(userSessionEventsService) + .Verify(x => x.ValidateSession(It.Is(ctx => + ctx.SessionId == fakeSession && ctx.SubjectId == fakeSubject && ctx.Client == fakeClient))); + } + + [Fact] + public async Task + ValidateAccessTokenAsync_WhenTokenContainsSubjectSessionClaims_AndSessionValid_ShouldReturnDecoratedServiceResult() + { + const string fakeToken = "fake_token"; + const string fakeSession = "fakeSession"; + const string fakeSubject = "fakeSubject"; + Client fakeClient = new Client(); + + var fakeResult = new TokenValidationResult + { + Claims = + [ + new Claim(JwtClaimTypes.SessionId, fakeSession), + new Claim(JwtClaimTypes.Subject, fakeSubject), + ], + Client = fakeClient, IsError = false, + }; + + Mock.Get(decoratedService) + .Setup(x => x.ValidateAccessTokenAsync(fakeToken, It.IsAny())) + .ReturnsAsync(fakeResult); + + Mock.Get(userSessionEventsService) + .Setup(x => x.ValidateSession(It.Is(ctx => + ctx.SessionId == fakeSession && ctx.SubjectId == fakeSubject && ctx.Client == fakeClient))) + .ReturnsAsync(true); + + DefaultServerSideSessionTokenValidator sut = CreateSut(); + + TokenValidationResult? actual = await sut.ValidateAccessTokenAsync(fakeToken); + + actual.Should().NotBeNull(); + actual.Should().Be(fakeResult); + + Mock.Get(userSessionEventsService) + .Verify(x => x.ValidateSession(It.Is(ctx => + ctx.SessionId == fakeSession && ctx.SubjectId == fakeSubject && ctx.Client == fakeClient))); + } + + [Fact] + public async Task ValidateIdentityTokenAsync_ShouldJustUseDecoratedService() + { + string fakeToken = "fake_refresh_token"; + string fakeClientId = ""; + bool fakeValidateLifetime = false; + + TokenValidationResult fakeResult = new TokenValidationResult(); + + DefaultServerSideSessionTokenValidator sut = CreateSut(); + + Mock.Get(decoratedService) + .Setup(x => x.ValidateIdentityTokenAsync(fakeToken, fakeClientId, fakeValidateLifetime)) + .ReturnsAsync(fakeResult); + + TokenValidationResult actual = + await sut.ValidateIdentityTokenAsync(fakeToken, fakeClientId, fakeValidateLifetime); + + Mock.Get(decoratedService) + .Verify(x => x.ValidateIdentityTokenAsync(fakeToken, fakeClientId, fakeValidateLifetime)); + + actual.Should().Be(fakeResult); + } + + [Fact] + public async Task PublicMethods_WithCustomisedLogic_WhenCalled_ShouldTelemetryTrace() + { + string fakeToken = "fake_refresh_token"; + + List<(Func actMethod, string traceMethodName)> methods + = + [ + (store => store.ValidateAccessTokenAsync(fakeToken), "ValidateAccessTokenAsync"), + ]; + + DefaultServerSideSessionTokenValidator 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.Validation, sut, method.traceMethodName), Times.Once); + Mock.Get(trace).Verify(t => t.Dispose(), Times.Once); + } + + // Assert all methods covered + // typeof(DefaultServerSideSessionTokenValidator).GetMethods() + // .Where(m => m is { IsPublic: true, IsStatic: false, IsSpecialName: false }) + // .Where(m => m.DeclaringType == typeof(DefaultServerSideSessionTokenValidator)) + // .Select(m => m.Name) + // .Distinct() + // .Should().BeEquivalentTo(methods.Select(m => m.traceMethodName)); + } +} \ No newline at end of file From aa5ff787609dc7907a06cce6d39e0f21b9f143d9 Mon Sep 17 00:00:00 2001 From: James Britton Date: Fri, 28 Aug 2026 13:22:26 +0100 Subject: [PATCH 28/59] fix: missing comments, and comment corrections --- .../src/Models/Contexts/ValidateUserSessionEventContext.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Open.IdentityServer/src/Models/Contexts/ValidateUserSessionEventContext.cs b/src/Open.IdentityServer/src/Models/Contexts/ValidateUserSessionEventContext.cs index 12f166c08..53c323c22 100644 --- a/src/Open.IdentityServer/src/Models/Contexts/ValidateUserSessionEventContext.cs +++ b/src/Open.IdentityServer/src/Models/Contexts/ValidateUserSessionEventContext.cs @@ -6,7 +6,7 @@ namespace Open.IdentityServer.Models; public class ValidateUserSessionEventContext: UserSessionEventContext { /// - /// + /// Client of the user session the event has been triggered for /// public Client Client { get; set; } } \ No newline at end of file From 5dff92495ca0e578dfcd7a78be616a95f712c8bb Mon Sep 17 00:00:00 2001 From: James Britton Date: Fri, 28 Aug 2026 14:58:50 +0100 Subject: [PATCH 29/59] feat: registered token validators for server side sessions fix: broken dependency for user session events service fix: used correct decorator pattern --- .../BuilderExtensions/Additional.cs | 4 + .../DefaultUserSessionEventsService.cs | 34 ++- ...ultServerSideSessionRefreshTokenService.cs | 10 +- .../DefaultServerSideSessionTokenValidator.cs | 12 +- .../BuilderExtensions/AdditionalTests.cs | 47 ++- .../DefaultUserSessionEventsServiceTests.cs | 277 ++++++++++-------- ...rverSideSessionRefreshTokenServiceTests.cs | 3 +- ...ultServerSideSessionTokenValidatorTests.cs | 3 +- 8 files changed, 241 insertions(+), 149 deletions(-) diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs index aad5d339b..0c696afd5 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs @@ -475,6 +475,10 @@ public static IIdentityServerBuilder AddServerSideSessions(this IIdentityServerB builder.Services.AddSingleton, PostConfigureSessionStoreCookieAuthOptions>(); builder.Services.AddScoped(); + // Token Validators + builder.Services.AddTransientDecorator(); + builder.Services.AddTransientDecorator(); + // provide default in-memory implementation, not suitable for most production scenarios (following pattern implemented with existing stores) builder.Services.TryAddSingleton(); diff --git a/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs b/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs index 1d5f288b4..74da31edd 100644 --- a/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs +++ b/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Open.IdentityServer.Configuration; using Open.IdentityServer.Models; @@ -20,8 +21,7 @@ namespace Open.IdentityServer.Services.Default; /// client store /// persisted grant store /// back channel logout service -/// server side auth ticket store -/// server side session entity store +/// service provider /// IdentityServer options /// telemetry service /// time provider @@ -30,8 +30,7 @@ public class DefaultUserSessionEventsService( IClientStore clientStore, IPersistedGrantStore persistedGrantStore, IBackChannelLogoutService backChannelLogoutService, - IServerSessionTicketStore? serverSessionTicketStore, - IIdentityServerServerSideSessionStore? identityServerServerSideSessionStore, + IServiceProvider serviceProvider, IdentityServerOptions idsOptions, ITelemetryService telemetry, TimeProvider timeProvider, @@ -40,11 +39,11 @@ public class DefaultUserSessionEventsService( /// public async Task HandleUserSessionLogout(EndUserSessionEventContext sessionEventContext) { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Services, this); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionEventContext.SessionId); ArgumentException.ThrowIfNullOrWhiteSpace(sessionEventContext.SubjectId); - using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Services, this); - if (sessionEventContext.ClientIds.Length == 0) { logger.LogInformation("no clients linked to session, nothing to be done"); @@ -64,14 +63,14 @@ await backChannelLogoutService.SendLogoutNotificationsAsync(new LogoutNotificati /// public async Task HandleUserSessionExpiry(EndUserSessionEventContext sessionEventContext) { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Services, this); + ArgumentException.ThrowIfNullOrWhiteSpace(sessionEventContext.SessionId); ArgumentException.ThrowIfNullOrWhiteSpace(sessionEventContext.SubjectId); - using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Services, this); - - var clientToNotify = await EndSessionForClients(sessionEventContext); + string[]? clientToNotify = await EndSessionForClients(sessionEventContext); - var backChannelClients = (idsOptions.ServerSideSessions.ExpiredSessionsTriggerBackchannelLogout + List backChannelClients = (idsOptions.ServerSideSessions.ExpiredSessionsTriggerBackchannelLogout ? sessionEventContext.ClientIds : clientToNotify ?? []).ToList(); @@ -92,13 +91,18 @@ await backChannelLogoutService.SendLogoutNotificationsAsync(new LogoutNotificati /// public async Task ValidateSession(ValidateUserSessionEventContext sessionEventContext) { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Services, this); + + IServerSessionTicketStore? serverSessionTicketStore = serviceProvider.GetService(); + IIdentityServerServerSideSessionStore? identityServerServerSideSessionStore = serviceProvider.GetService(); + if (serverSessionTicketStore == null || identityServerServerSideSessionStore == null || !ShouldCoordinateLifetimes(sessionEventContext.Client)) { return true; } - var sessions = + List sessions = (await serverSessionTicketStore.FilterServerAuthenticationTickets(sessionEventContext.SubjectId, sessionEventContext.SessionId)) .ToList(); @@ -110,9 +114,9 @@ public async Task ValidateSession(ValidateUserSessionEventContext sessionE return false; } - foreach (var session in sessions) + foreach (AuthenticationTicketFilterResult session in sessions) { - var diff = session.Session.Expires - session.Session.Renewed; + TimeSpan? diff = session.Session.Expires - session.Session.Renewed; session.Session.Renewed = timeProvider.GetUtcNow().UtcDateTime; session.Session.Expires = session.Session.Renewed + diff; @@ -135,7 +139,7 @@ public async Task ValidateSession(ValidateUserSessionEventContext sessionE private async Task EndSessionForClients(EndUserSessionEventContext sessionEventContext) { - var clientIds = await ClientIdsToCoordinate(sessionEventContext).ToArrayAsync(); + string[] clientIds = await ClientIdsToCoordinate(sessionEventContext).ToArrayAsync(); if (clientIds.Length == 0) { @@ -158,7 +162,7 @@ private async IAsyncEnumerable ClientIdsToCoordinate(EndUserSessionEvent { foreach (string clientId in sessionEventContext.ClientIds ?? []) { - var client = await clientStore.FindClientByIdAsync(clientId); + Client? client = await clientStore.FindClientByIdAsync(clientId); if (ShouldCoordinateLifetimes(client)) { diff --git a/src/Open.IdentityServer/src/Validation/Default/DefaultServerSideSessionRefreshTokenService.cs b/src/Open.IdentityServer/src/Validation/Default/DefaultServerSideSessionRefreshTokenService.cs index 47d7ba1cf..918d5d99c 100644 --- a/src/Open.IdentityServer/src/Validation/Default/DefaultServerSideSessionRefreshTokenService.cs +++ b/src/Open.IdentityServer/src/Validation/Default/DefaultServerSideSessionRefreshTokenService.cs @@ -3,7 +3,9 @@ #nullable enable +using System; using System.Threading.Tasks; +using Open.IdentityServer.Configuration.DependencyInjection; using Open.IdentityServer.Models; using Open.IdentityServer.Services; @@ -12,14 +14,16 @@ namespace Open.IdentityServer.Validation; /// /// /// -/// +/// /// /// -public class DefaultServerSideSessionRefreshTokenService( - IRefreshTokenService decoratedService, +internal class DefaultServerSideSessionRefreshTokenService( + Decorator decorator, IUserSessionEventsService userSessionEventsService, ITelemetryService telemetry): IRefreshTokenService { + private IRefreshTokenService decoratedService = decorator.Instance ?? throw new ArgumentNullException(nameof(decorator)); + /// /// /// diff --git a/src/Open.IdentityServer/src/Validation/Default/DefaultServerSideSessionTokenValidator.cs b/src/Open.IdentityServer/src/Validation/Default/DefaultServerSideSessionTokenValidator.cs index 782d391e9..3fa6ed0a2 100644 --- a/src/Open.IdentityServer/src/Validation/Default/DefaultServerSideSessionTokenValidator.cs +++ b/src/Open.IdentityServer/src/Validation/Default/DefaultServerSideSessionTokenValidator.cs @@ -3,9 +3,11 @@ #nullable enable +using System; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; +using Open.IdentityServer.Configuration.DependencyInjection; using Open.IdentityServer.Models; using Open.IdentityServer.Services; @@ -14,14 +16,16 @@ namespace Open.IdentityServer.Validation; /// /// /// -/// +/// /// /// -public class DefaultServerSideSessionTokenValidator( - ITokenValidator decoratedService, +internal class DefaultServerSideSessionTokenValidator( + Decorator decorator, IUserSessionEventsService userSessionEventsService, - ITelemetryService telemetry) : ITokenValidator + ITelemetryService telemetry): ITokenValidator { + private ITokenValidator decoratedService = decorator?.Instance ?? throw new ArgumentNullException(nameof(decorator)); + /// /// /// 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 0d2d2379a..bdc0a8d08 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 @@ -8,8 +8,11 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Open.IdentityServer.Configuration; +using Open.IdentityServer.Configuration.DependencyInjection; using Open.IdentityServer.Models; +using Open.IdentityServer.Services; using Open.IdentityServer.Stores; +using Open.IdentityServer.Validation; using Xunit; namespace Open.IdentityServer.UnitTests.Configuration.DependencyInjection; @@ -23,6 +26,9 @@ public void AddServerSideSessions_WhenNoStoreConfigured_ShouldConfigureServerSid { IIdentityServerBuilder builder = new IdentityServerBuilder(serviceCollection); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.AddServerSideSessions(); serviceCollection.Should().ContainSingle(d => @@ -35,6 +41,24 @@ public void AddServerSideSessions_WhenNoStoreConfigured_ShouldConfigureServerSid d.ImplementationType == typeof(ServerSessionTicketStore) && d.Lifetime == ServiceLifetime.Scoped); + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(Decorator) && + d.Lifetime == ServiceLifetime.Transient); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(ITokenValidator) && + d.ImplementationType == typeof(DefaultServerSideSessionTokenValidator) && + d.Lifetime == ServiceLifetime.Transient); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(Decorator) && + d.Lifetime == ServiceLifetime.Transient); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(IRefreshTokenService) && + d.ImplementationType == typeof(DefaultServerSideSessionRefreshTokenService) && + d.Lifetime == ServiceLifetime.Transient); + serviceCollection.Should().ContainSingle(d => d.ServiceType == typeof(IIdentityServerServerSideSessionStore) && d.ImplementationType == typeof(InMemorySessionStore) && @@ -46,7 +70,10 @@ public void AddServerSideSessions_WhenStoreConfigured_ShouldConfigureServerSideS { IIdentityServerBuilder builder = new IdentityServerBuilder(serviceCollection); - serviceCollection.AddSingleton(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + builder.Services.AddSingleton(); builder.AddServerSideSessions(); @@ -60,6 +87,24 @@ public void AddServerSideSessions_WhenStoreConfigured_ShouldConfigureServerSideS d.ImplementationType == typeof(ServerSessionTicketStore) && d.Lifetime == ServiceLifetime.Scoped); + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(Decorator) && + d.Lifetime == ServiceLifetime.Transient); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(ITokenValidator) && + d.ImplementationType == typeof(DefaultServerSideSessionTokenValidator) && + d.Lifetime == ServiceLifetime.Transient); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(Decorator) && + d.Lifetime == ServiceLifetime.Transient); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(IRefreshTokenService) && + d.ImplementationType == typeof(DefaultServerSideSessionRefreshTokenService) && + d.Lifetime == ServiceLifetime.Transient); + serviceCollection.Should().NotContain(d => d.ServiceType == typeof(IIdentityServerServerSideSessionStore) && d.ImplementationType == typeof(InMemorySessionStore) && diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs index d47f7a58a..10585279f 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/DefaultUserSessionEventsServiceTests.cs @@ -28,29 +28,39 @@ public class DefaultUserSessionEventsServiceTests private readonly IBackChannelLogoutService backChannelLogoutService = Mock.Of(); private readonly IClientStore clientStore = Mock.Of(); private readonly IPersistedGrantStore persistedGrantStore = Mock.Of(); - private IServerSessionTicketStore serverSessionTicketStore = Mock.Of(); - private IIdentityServerServerSideSessionStore identityServerServerSideSessionStore = Mock.Of(); private readonly IdentityServerOptions idsOptions = new(); + private readonly IServiceProvider serviceProvider = Mock.Of(); private readonly ITelemetryService telemetry = Mock.Of(); private readonly ITrace trace = Mock.Of(); private readonly FakeTimeProvider timeProvider = new(); private readonly ILogger logger = TestLogger.Create(); + + // Server-Sessions services + private readonly IServerSessionTicketStore serverSessionTicketStore = Mock.Of(); + private readonly IIdentityServerServerSideSessionStore identityServerServerSideSessionStore = Mock.Of(); private readonly DateTime fakeNow = new(2026, 2, 1, 12, 0, 0, DateTimeKind.Utc); public DefaultUserSessionEventsServiceTests() { timeProvider.SetUtcNow(fakeNow); + + Mock.Get(serviceProvider) + .Setup(x => x.GetService(typeof(IServerSessionTicketStore))) + .Returns(serverSessionTicketStore); + + Mock.Get(serviceProvider) + .Setup(x => x.GetService(typeof(IIdentityServerServerSideSessionStore))) + .Returns(identityServerServerSideSessionStore); } private DefaultUserSessionEventsService CreateSut() => new( - clientStore, - persistedGrantStore, - backChannelLogoutService, - serverSessionTicketStore, - identityServerServerSideSessionStore, - idsOptions, - telemetry, + clientStore, + persistedGrantStore, + backChannelLogoutService, + serviceProvider, + idsOptions, + telemetry, timeProvider, logger); @@ -61,7 +71,7 @@ public DefaultUserSessionEventsServiceTests() [InlineData(null, "subjectId")] [InlineData("", "subjectId")] [InlineData(" ", "subjectId")] - public async Task HandleUserSessionLogout_WhenInvalidSubjectId_ShouldThrowArgumentException(string subjectId, string sessionId) + public async Task HandleUserSessionLogout_WhenInvalidSubjectId_ShouldThrowArgumentException(string? subjectId, string? sessionId) { EndUserSessionEventContext endUserSessionCtx = new() { @@ -126,22 +136,22 @@ public async Task HandleUserSessionLogout_WhenServerDefaultCoordinateLifetimeSet await sut.HandleUserSessionLogout(endUserSessionCtx); Mock.Get(persistedGrantStore) - .Verify(x => x.RemoveAllAsync(It.Is(x => - x.SubjectId == "fakeSubject" && - x.SessionId == "fakeSession" && - Enumerable.Contains(x.ClientIds, "fake-client-one") && - Enumerable.Contains(x.ClientIds, "fake-client-two") && - Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && - Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && - Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + .Verify(x => x.RemoveAllAsync(It.Is(f => + f.SubjectId == "fakeSubject" && + f.SessionId == "fakeSession" && + Enumerable.Contains(f.ClientIds, "fake-client-one") && + Enumerable.Contains(f.ClientIds, "fake-client-two") && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); Mock.Get(backChannelLogoutService) - .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => - x.SubjectId == "fakeSubject" && - x.SessionId == "fakeSession" && - x.ClientIds.Contains("fake-client-one") && - x.ClientIds.Contains("fake-client-two") && - x.ClientIds.Contains("fake-client-three")))); + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(c => + c.SubjectId == "fakeSubject" && + c.SessionId == "fakeSession" && + c.ClientIds.Contains("fake-client-one") && + c.ClientIds.Contains("fake-client-two") && + c.ClientIds.Contains("fake-client-three")))); } [Fact] @@ -168,21 +178,21 @@ public async Task HandleUserSessionLogout_WhenServerDefaultCoordinateLifetimeSet await sut.HandleUserSessionLogout(endUserSessionCtx); Mock.Get(persistedGrantStore) - .Verify(x => x.RemoveAllAsync(It.Is(x => - x.SubjectId == "fakeSubject" && - x.SessionId == "fakeSession" && - Enumerable.Contains(x.ClientIds, "fake-client-two") && - Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && - Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && - Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + .Verify(x => x.RemoveAllAsync(It.Is(f => + f.SubjectId == "fakeSubject" && + f.SessionId == "fakeSession" && + Enumerable.Contains(f.ClientIds, "fake-client-two") && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); Mock.Get(backChannelLogoutService) - .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => - x.SubjectId == "fakeSubject" && - x.SessionId == "fakeSession" && - x.ClientIds.Contains("fake-client-one") && - x.ClientIds.Contains("fake-client-two") && - x.ClientIds.Contains("fake-client-three")))); + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(c => + c.SubjectId == "fakeSubject" && + c.SessionId == "fakeSession" && + c.ClientIds.Contains("fake-client-one") && + c.ClientIds.Contains("fake-client-two") && + c.ClientIds.Contains("fake-client-three")))); } [Fact] @@ -207,20 +217,20 @@ public async Task HandleUserSessionLogout_WhenClientIdNotFound_ShouldExcludeClie await sut.HandleUserSessionLogout(endUserSessionCtx); Mock.Get(persistedGrantStore) - .Verify(x => x.RemoveAllAsync(It.Is(x => - x.SubjectId == "fakeSubject" && - x.SessionId == "fakeSession" && - Enumerable.Contains(x.ClientIds, "fake-client-one") && - Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && - Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && - Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + .Verify(x => x.RemoveAllAsync(It.Is(f => + f.SubjectId == "fakeSubject" && + f.SessionId == "fakeSession" && + Enumerable.Contains(f.ClientIds, "fake-client-one") && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); Mock.Get(backChannelLogoutService) - .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => - x.SubjectId == "fakeSubject" && - x.SessionId == "fakeSession" && - x.ClientIds.Contains("fake-client-one") && - x.ClientIds.Contains("fake-non-found")))); + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(c => + c.SubjectId == "fakeSubject" && + c.SessionId == "fakeSession" && + c.ClientIds.Contains("fake-client-one") && + c.ClientIds.Contains("fake-non-found")))); } [Theory] @@ -230,7 +240,7 @@ public async Task HandleUserSessionLogout_WhenClientIdNotFound_ShouldExcludeClie [InlineData(null, "subjectId")] [InlineData("", "subjectId")] [InlineData(" ", "subjectId")] - public async Task HandleUserSessionExpiry_WhenInvalidSubjectId_ShouldThrowArgumentException(string subjectId, string sessionId) + public async Task HandleUserSessionExpiry_WhenInvalidSubjectId_ShouldThrowArgumentException(string? subjectId, string? sessionId) { EndUserSessionEventContext endUserSessionCtx = new() { @@ -295,21 +305,21 @@ public async Task HandleUserSessionExpiry_WhenServerDefaultCoordinateLifetimeSet await sut.HandleUserSessionExpiry(endUserSessionCtx); Mock.Get(persistedGrantStore) - .Verify(x => x.RemoveAllAsync(It.Is(x => - x.SubjectId == "fakeSubject" && - x.SessionId == "fakeSession" && - Enumerable.Contains(x.ClientIds, "fake-client-one") && - Enumerable.Contains(x.ClientIds, "fake-client-two") && - Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && - Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && - Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + .Verify(x => x.RemoveAllAsync(It.Is(f => + f.SubjectId == "fakeSubject" && + f.SessionId == "fakeSession" && + Enumerable.Contains(f.ClientIds, "fake-client-one") && + Enumerable.Contains(f.ClientIds, "fake-client-two") && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); Mock.Get(backChannelLogoutService) - .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => - x.SubjectId == "fakeSubject" && - x.SessionId == "fakeSession" && - x.ClientIds.Contains("fake-client-one") && - x.ClientIds.Contains("fake-client-two")))); + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(c => + c.SubjectId == "fakeSubject" && + c.SessionId == "fakeSession" && + c.ClientIds.Contains("fake-client-one") && + c.ClientIds.Contains("fake-client-two")))); } [Fact] @@ -336,19 +346,19 @@ public async Task HandleUserSessionExpiry_WhenServerDefaultCoordinateLifetimeSet await sut.HandleUserSessionExpiry(endUserSessionCtx); Mock.Get(persistedGrantStore) - .Verify(x => x.RemoveAllAsync(It.Is(x => - x.SubjectId == "fakeSubject" && - x.SessionId == "fakeSession" && - Enumerable.Contains(x.ClientIds, "fake-client-two") && - Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && - Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && - Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + .Verify(x => x.RemoveAllAsync(It.Is(f => + f.SubjectId == "fakeSubject" && + f.SessionId == "fakeSession" && + Enumerable.Contains(f.ClientIds, "fake-client-two") && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); Mock.Get(backChannelLogoutService) - .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => - x.SubjectId == "fakeSubject" && - x.SessionId == "fakeSession" && - x.ClientIds.Contains("fake-client-two")))); + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(c => + c.SubjectId == "fakeSubject" && + c.SessionId == "fakeSession" && + c.ClientIds.Contains("fake-client-two")))); } [Fact] @@ -375,21 +385,21 @@ public async Task HandleUserSessionExpiry_WhenExpiredSessionsTriggerBackchannelL await sut.HandleUserSessionExpiry(endUserSessionCtx); Mock.Get(persistedGrantStore) - .Verify(x => x.RemoveAllAsync(It.Is(x => - x.SubjectId == "fakeSubject" && - x.SessionId == "fakeSession" && - Enumerable.Contains(x.ClientIds, "fake-client-two") && - Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && - Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && - Enumerable.Contains(x.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); + .Verify(x => x.RemoveAllAsync(It.Is(f => + f.SubjectId == "fakeSubject" && + f.SessionId == "fakeSession" && + Enumerable.Contains(f.ClientIds, "fake-client-two") && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.RefreshToken) && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.ReferenceToken) && + Enumerable.Contains(f.Types, IdentityServerConstants.PersistedGrantTypes.AuthorizationCode)))); Mock.Get(backChannelLogoutService) - .Verify(x => x.SendLogoutNotificationsAsync(It.Is(x => - x.SubjectId == "fakeSubject" && - x.SessionId == "fakeSession" && - x.ClientIds.Contains("fake-client-one") && - x.ClientIds.Contains("fake-client-two") && - x.ClientIds.Contains("fake-client-three")))); + .Verify(x => x.SendLogoutNotificationsAsync(It.Is(c => + c.SubjectId == "fakeSubject" && + c.SessionId == "fakeSession" && + c.ClientIds.Contains("fake-client-one") && + c.ClientIds.Contains("fake-client-two") && + c.ClientIds.Contains("fake-client-three")))); } [Fact] @@ -412,45 +422,14 @@ public async Task HandleUserSessionExpiry_WhenClientIdNotFound_ShouldExcludeClie Mock.Get(backChannelLogoutService) .Verify(x => x.SendLogoutNotificationsAsync(It.IsAny()), Times.Never); } - - [Fact] - public async Task HandleUserSessionLogout_WhenCalled_ShouldInitiateTelemetryTrace() - { - Mock.Get(telemetry) - .Setup(t => t.Trace(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(trace); - - DefaultUserSessionEventsService sut = CreateSut(); - await sut.HandleUserSessionLogout(new EndUserSessionEventContext { SessionId = "session", SubjectId = "subject" }); - - Mock.Get(telemetry) - .Verify(t => t.Trace( - TelemetryConstants.TraceCategories.Services, sut, "HandleUserSessionLogout")); - Mock.Get(trace) - .Verify(t => t.Dispose(), Times.Once); - } - - [Fact] - public async Task HandleUserSessionExpiry_WhenCalled_ShouldInitiateTelemetryTrace() - { - Mock.Get(telemetry) - .Setup(t => t.Trace(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(trace); - - DefaultUserSessionEventsService sut = CreateSut(); - await sut.HandleUserSessionExpiry(new EndUserSessionEventContext { SessionId = "session", SubjectId = "subject" }); - - Mock.Get(telemetry) - .Verify(t => t.Trace( - TelemetryConstants.TraceCategories.Services, sut, "HandleUserSessionExpiry")); - Mock.Get(trace) - .Verify(t => t.Dispose(), Times.Once); - } [Fact] private async Task ValidateRefreshTokenAsync_WhenAuthTicketStoreRegistered_ShouldReturnTrue() { - serverSessionTicketStore = null!; + Mock.Get(serviceProvider) + .Setup(x => x.GetService(typeof(IServerSessionTicketStore))) + .Returns(null!); + ValidateUserSessionEventContext testCtx = new() { SubjectId = "fakeSubjectId", @@ -470,7 +449,10 @@ private async Task ValidateRefreshTokenAsync_WhenAuthTicketStoreRegistered_Shoul [Fact] private async Task ValidateRefreshTokenAsync_WhenNoSessionStoreRegistered_ShouldReturnTrue() { - identityServerServerSideSessionStore = null!; + Mock.Get(serviceProvider) + .Setup(x => x.GetService(typeof(IIdentityServerServerSideSessionStore))) + .Returns(null!); + ValidateUserSessionEventContext testCtx = new() { SubjectId = "fakeSubjectId", @@ -684,7 +666,7 @@ private async Task ValidateRefreshTokenAsync_CoordinationEnabledWithValidSession AuthenticationTicket? updatedTicket = null; Mock.Get(serverSessionTicketStore) .Setup(x => x.RenewAsync(It.IsAny(), It.IsAny())) - .Callback((k, x) => updatedTicket = x); + .Callback((_, x) => updatedTicket = x); DefaultUserSessionEventsService sut = CreateSut(); bool actual = await sut.ValidateSession(testCtx); @@ -700,6 +682,53 @@ private async Task ValidateRefreshTokenAsync_CoordinationEnabledWithValidSession .Verify(x => x.RenewAsync(It.IsAny(), It.IsAny())); } + [Fact] + public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace() + { + EndUserSessionEventContext endCtx = new EndUserSessionEventContext + { + SessionId = "sessionId", SubjectId = "subjectId", ClientIds = [], + }; + ValidateUserSessionEventContext validateCtx = new ValidateUserSessionEventContext + { + SessionId = "sessionId", SubjectId = "subjectId", Client = new Client(), + }; + + List<(Func actMethod, string traceMethodName)> methods = + [ + (store => store.HandleUserSessionLogout(endCtx), "HandleUserSessionLogout"), + (store => store.HandleUserSessionExpiry(endCtx), "HandleUserSessionExpiry"), + (store => store.ValidateSession(validateCtx), "ValidateSession"), + ]; + + DefaultUserSessionEventsService 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.Services, sut, method.traceMethodName), Times.Once); + Mock.Get(trace).Verify(t => t.Dispose(), Times.Once); + } + + // Assert all methods covered + typeof(DefaultUserSessionEventsService).GetMethods() + .Where(m => m is { IsPublic: true, IsStatic: false, IsSpecialName: false }) + .Where(m => m.DeclaringType == typeof(DefaultUserSessionEventsService)) + .Select(m => m.Name) + .Distinct() + .Should().BeEquivalentTo(methods.Select(m => m.traceMethodName)); + } + private void SetupClientStore(IEnumerable clients) { foreach (Client client in clients) diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/Default/DefaultServerSideSessionRefreshTokenServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/Default/DefaultServerSideSessionRefreshTokenServiceTests.cs index fe610f481..07b0da6c2 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/Default/DefaultServerSideSessionRefreshTokenServiceTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/Default/DefaultServerSideSessionRefreshTokenServiceTests.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using AwesomeAssertions; using Moq; +using Open.IdentityServer.Configuration.DependencyInjection; using Open.IdentityServer.Models; using Open.IdentityServer.Services; using Open.IdentityServer.Validation; @@ -24,7 +25,7 @@ public class DefaultServerSideSessionRefreshTokenServiceTests private ITelemetryService telemetry = Mock.Of(); private DefaultServerSideSessionRefreshTokenService CreateSut() => - new(decoratedService, userSessionEventsService, telemetry); + new(new Decorator(decoratedService), userSessionEventsService, telemetry); [Fact] public async Task ValidateRefreshTokenAsync_WhenDecoratedResultIsInvalid_ShouldNotValidateUserSession() diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/Default/DefaultServerSideSessionTokenValidatorTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/Default/DefaultServerSideSessionTokenValidatorTests.cs index 43bac9657..232c26f2a 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/Default/DefaultServerSideSessionTokenValidatorTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Validation/Default/DefaultServerSideSessionTokenValidatorTests.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using AwesomeAssertions; using Moq; +using Open.IdentityServer.Configuration.DependencyInjection; using Open.IdentityServer.Models; using Open.IdentityServer.Services; using Open.IdentityServer.Validation; @@ -24,7 +25,7 @@ public class DefaultServerSideSessionTokenValidatorTests private ITelemetryService telemetry = Mock.Of(); private DefaultServerSideSessionTokenValidator CreateSut() => - new(decoratedService, userSessionEventsService, telemetry); + new(new Decorator(decoratedService), userSessionEventsService, telemetry); [Fact] From 32463c85285717e5c752f3a12f375edbfb2e7a45 Mon Sep 17 00:00:00 2001 From: James Britton Date: Wed, 2 Sep 2026 09:42:25 +0100 Subject: [PATCH 30/59] fix: correct ticket store registration in DI to use extended interface --- .../DependencyInjection/BuilderExtensions/Additional.cs | 2 +- .../PostConfigureSessionStoreCookieAuthOptions.cs | 3 ++- .../DependencyInjection/BuilderExtensions/AdditionalTests.cs | 4 ++-- .../PostConfigureSessionStoreCookieAuthOptionsTests.cs | 3 ++- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs index 0c696afd5..5104e7768 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs @@ -473,7 +473,7 @@ public static IIdentityServerBuilder AddUserSession(this IIdentityServerBuild public static IIdentityServerBuilder AddServerSideSessions(this IIdentityServerBuilder builder) { builder.Services.AddSingleton, PostConfigureSessionStoreCookieAuthOptions>(); - builder.Services.AddScoped(); + builder.Services.AddScoped(); // Token Validators builder.Services.AddTransientDecorator(); diff --git a/src/Open.IdentityServer/src/Configuration/PostConfigureSessionStoreCookieAuthOptions.cs b/src/Open.IdentityServer/src/Configuration/PostConfigureSessionStoreCookieAuthOptions.cs index 3f6bd75de..b8301b091 100644 --- a/src/Open.IdentityServer/src/Configuration/PostConfigureSessionStoreCookieAuthOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/PostConfigureSessionStoreCookieAuthOptions.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Extensions.Options; +using Open.IdentityServer.Stores; namespace Open.IdentityServer.Configuration; @@ -14,7 +15,7 @@ namespace Open.IdentityServer.Configuration; /// Open.IdentityServer options /// Authentication options public class PostConfigureSessionStoreCookieAuthOptions( - ITicketStore ticketStore, + IServerSessionTicketStore ticketStore, IdentityServerOptions idsOptions, IOptions authOptions): IPostConfigureOptions { 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 bdc0a8d08..216f537b7 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 @@ -37,7 +37,7 @@ public void AddServerSideSessions_WhenNoStoreConfigured_ShouldConfigureServerSid d.Lifetime == ServiceLifetime.Singleton); serviceCollection.Should().ContainSingle(d => - d.ServiceType == typeof(ITicketStore) && + d.ServiceType == typeof(IServerSessionTicketStore) && d.ImplementationType == typeof(ServerSessionTicketStore) && d.Lifetime == ServiceLifetime.Scoped); @@ -83,7 +83,7 @@ public void AddServerSideSessions_WhenStoreConfigured_ShouldConfigureServerSideS d.Lifetime == ServiceLifetime.Singleton); serviceCollection.Should().ContainSingle(d => - d.ServiceType == typeof(ITicketStore) && + d.ServiceType == typeof(IServerSessionTicketStore) && d.ImplementationType == typeof(ServerSessionTicketStore) && d.Lifetime == ServiceLifetime.Scoped); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/PostConfigureSessionStoreCookieAuthOptionsTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/PostConfigureSessionStoreCookieAuthOptionsTests.cs index 884c37b64..75032da19 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/PostConfigureSessionStoreCookieAuthOptionsTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Configuration/PostConfigureSessionStoreCookieAuthOptionsTests.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Options; using Moq; using Open.IdentityServer.Configuration; +using Open.IdentityServer.Stores; using Xunit; using AuthenticationOptions = Microsoft.AspNetCore.Authentication.AuthenticationOptions; @@ -13,7 +14,7 @@ namespace Open.IdentityServer.UnitTests.Configuration; public class PostConfigureSessionStoreCookieAuthOptionsTests { - private ITicketStore ticketStore = Mock.Of(); + private IServerSessionTicketStore ticketStore = Mock.Of(); private IdentityServerOptions idsOptions = new(); private IOptions authOptions = Mock.Of>(); From 7063d51363b12a37863bc3c7cbfeff15d37ec8b7 Mon Sep 17 00:00:00 2001 From: James Britton Date: Thu, 3 Sep 2026 14:05:25 +0100 Subject: [PATCH 31/59] pr: acting on review comments --- .../IdentityServerServerSideSessionStore.cs | 43 +++++++++---------- .../AuthenticationTicketFilterResult.cs | 5 +-- .../Contexts/EndUserSessionEventContext.cs | 2 +- .../Contexts/UserSessionEventContext.cs | 2 +- .../DefaultUserSessionEventsService.cs | 3 ++ .../src/Stores/IServerSessionTicketStore.cs | 3 ++ ...ultServerSideSessionRefreshTokenService.cs | 17 ++++---- 7 files changed, 40 insertions(+), 35 deletions(-) diff --git a/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs b/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs index 1f198940e..e962521bf 100644 --- a/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs +++ b/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs @@ -23,18 +23,18 @@ namespace Open.IdentityServer.EntityFramework.Stores; public class IdentityServerServerSideSessionStore( IPersistedGrantDbContext dbContext, ITelemetryService telemetry, - ILogger logger): IIdentityServerServerSideSessionStore + ILogger logger) : IIdentityServerServerSideSessionStore { /// public async Task GetSession(string key) { using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); - + ArgumentException.ThrowIfNullOrWhiteSpace(key); Entities.IdentityServerServerSideSessions? session = await dbContext.ServerSideSessions .SingleOrDefaultAsync(x => x.Key == key); - + return session?.ToModel(); } @@ -42,15 +42,16 @@ public class IdentityServerServerSideSessionStore( public async Task CreateSession(IdentityServerServerSideSessions session) { using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); - + ArgumentException.ThrowIfNullOrWhiteSpace(session.Key); - + Entities.IdentityServerServerSideSessions? existing = await dbContext.ServerSideSessions .SingleOrDefaultAsync(x => x.Key == session.Key); - + if (existing != null) { - logger.LogError("failed storing '{SessionKey}' session in database, session with key already exists", session.Key); + logger.LogError("failed storing '{SessionKey}' session in database, session with key already exists", + session.Key); return; } @@ -72,9 +73,9 @@ public async Task CreateSession(IdentityServerServerSideSessions session) public async Task UpdateSession(IdentityServerServerSideSessions session) { using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); - + ArgumentException.ThrowIfNullOrWhiteSpace(session.Key); - + Entities.IdentityServerServerSideSessions? existing = await dbContext.ServerSideSessions .SingleOrDefaultAsync(x => x.Key == session.Key); @@ -83,9 +84,9 @@ public async Task UpdateSession(IdentityServerServerSideSessions session) logger.LogError("failed updating '{SessionKey}' session in database, session not found", session.Key); return; } - + session.UpdateEntity(existing); - + try { await dbContext.SaveChangesAsync(); @@ -100,12 +101,12 @@ public async Task UpdateSession(IdentityServerServerSideSessions session) public async Task DeleteSession(string key) { using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); - + ArgumentException.ThrowIfNullOrWhiteSpace(key); - + Entities.IdentityServerServerSideSessions? existing = await dbContext.ServerSideSessions .SingleOrDefaultAsync(x => x.Key == key); - + if (existing == null) { logger.LogError("failed deleting '{SessionKey}' session in database, session not found", key); @@ -113,7 +114,7 @@ public async Task DeleteSession(string key) } dbContext.ServerSideSessions.Remove(existing); - + try { await dbContext.SaveChangesAsync(); @@ -128,15 +129,13 @@ public async Task DeleteSession(string key) public async Task> FilterSessions(string subjectId, string sessionId) { using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); - + ArgumentException.ThrowIfNullOrWhiteSpace(subjectId); ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); - var result = dbContext.ServerSideSessions.ToList(); - - return await dbContext.ServerSideSessions - .Where(x => x.SubjectId == subjectId && x.SessionId == sessionId) - .Select(x => x.ToModel()) - .ToListAsync(); + return (await dbContext.ServerSideSessions + .Where(x => x.SubjectId == subjectId && x.SessionId == sessionId) + .ToListAsync()) + .Select(x => x.ToModel()); } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Models/AuthenticationTicketFilterResult.cs b/src/Open.IdentityServer/src/Models/AuthenticationTicketFilterResult.cs index 42feca41c..5b9a9c4c1 100644 --- a/src/Open.IdentityServer/src/Models/AuthenticationTicketFilterResult.cs +++ b/src/Open.IdentityServer/src/Models/AuthenticationTicketFilterResult.cs @@ -9,13 +9,12 @@ namespace Open.IdentityServer.Models; /// -/// Object for containing the session object and deseriakised auth ticket from the -/// FilterServerAuthenticationTickets methods +/// Container for the session model and deserialized auth ticket from the FilterServerAuthenticationTickets method /// public class AuthenticationTicketFilterResult { /// - /// Session entity from the database + /// Session model returned from filtering /// public IdentityServerServerSideSessions Session { get; set; } = null!; diff --git a/src/Open.IdentityServer/src/Models/Contexts/EndUserSessionEventContext.cs b/src/Open.IdentityServer/src/Models/Contexts/EndUserSessionEventContext.cs index cff844712..94261f03c 100644 --- a/src/Open.IdentityServer/src/Models/Contexts/EndUserSessionEventContext.cs +++ b/src/Open.IdentityServer/src/Models/Contexts/EndUserSessionEventContext.cs @@ -6,7 +6,7 @@ namespace Open.IdentityServer.Models; public class EndUserSessionEventContext: UserSessionEventContext { /// - /// Collection of ClientId active within the session. + /// Collection of ClientIds active within the session. /// public string[] ClientIds { get; set; } = []; } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs b/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs index 93363b400..276395175 100644 --- a/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs +++ b/src/Open.IdentityServer/src/Models/Contexts/UserSessionEventContext.cs @@ -14,7 +14,7 @@ public class UserSessionEventContext public string SubjectId { get; set; } /// - /// Session identifier of the session the event has been triggered for + /// Session identifier for the event that has been triggered. /// public string SessionId { get; set; } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs b/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs index 74da31edd..4b4e8c302 100644 --- a/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs +++ b/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs @@ -96,6 +96,9 @@ public async Task ValidateSession(ValidateUserSessionEventContext sessionE IServerSessionTicketStore? serverSessionTicketStore = serviceProvider.GetService(); IIdentityServerServerSideSessionStore? identityServerServerSideSessionStore = serviceProvider.GetService(); + // Checking if server-side sessions have been configured by the presence of registered server-side session stores + // also checking if client lifetime coordination has been enabled. If any of these conditions are not met, then + // the validate session methods should exit early without validating. if (serverSessionTicketStore == null || identityServerServerSideSessionStore == null || !ShouldCoordinateLifetimes(sessionEventContext.Client)) { diff --git a/src/Open.IdentityServer/src/Stores/IServerSessionTicketStore.cs b/src/Open.IdentityServer/src/Stores/IServerSessionTicketStore.cs index 7b46a962a..0a68cede9 100644 --- a/src/Open.IdentityServer/src/Stores/IServerSessionTicketStore.cs +++ b/src/Open.IdentityServer/src/Stores/IServerSessionTicketStore.cs @@ -1,3 +1,6 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; diff --git a/src/Open.IdentityServer/src/Validation/Default/DefaultServerSideSessionRefreshTokenService.cs b/src/Open.IdentityServer/src/Validation/Default/DefaultServerSideSessionRefreshTokenService.cs index 918d5d99c..d5ae28e29 100644 --- a/src/Open.IdentityServer/src/Validation/Default/DefaultServerSideSessionRefreshTokenService.cs +++ b/src/Open.IdentityServer/src/Validation/Default/DefaultServerSideSessionRefreshTokenService.cs @@ -12,11 +12,11 @@ namespace Open.IdentityServer.Validation; /// -/// +/// Decorator for , adds server side sessions functionality to refresh token validation /// -/// -/// -/// +/// existing implementation of to be decorated +/// user session event service +/// telemetry service internal class DefaultServerSideSessionRefreshTokenService( Decorator decorator, IUserSessionEventsService userSessionEventsService, @@ -25,11 +25,12 @@ internal class DefaultServerSideSessionRefreshTokenService( private IRefreshTokenService decoratedService = decorator.Instance ?? throw new ArgumentNullException(nameof(decorator)); /// - /// + /// Validates refresh token as normal using the decorated service, then if successfull validates that there are valid + /// sessions associated with the refresh token. /// - /// - /// - /// + /// refresh token to be validated + /// client to validate refresh token against + /// A task that resolves to a indicating whether the refresh token is valid for the specified client. public async Task ValidateRefreshTokenAsync(string token, Client client) { using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Validation, this); From 61116a6bf107611529c824072ead2075c329b5db Mon Sep 17 00:00:00 2001 From: James Britton Date: Fri, 4 Sep 2026 13:32:32 +0100 Subject: [PATCH 32/59] feat: added settings for session cleanup functionality --- .../src/Options/OperationalStoreOptions.cs | 16 ++++----- .../Options/ServerSideSessionsOptions.cs | 35 +++++++++++++++++++ 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/EntityFramework.Storage/src/Options/OperationalStoreOptions.cs b/src/EntityFramework.Storage/src/Options/OperationalStoreOptions.cs index dafc5c456..59bd5095b 100644 --- a/src/EntityFramework.Storage/src/Options/OperationalStoreOptions.cs +++ b/src/EntityFramework.Storage/src/Options/OperationalStoreOptions.cs @@ -25,6 +25,14 @@ public class OperationalStoreOptions: StoreOptions /// public TableConfiguration DeviceFlowCodes { get; set; } = new TableConfiguration("DeviceCodes"); + /// + /// Gets or sets the server-side sessions table configuration. + /// + /// + /// The server-side sessions' config. + /// + public TableConfiguration ServerSideSessions { get; set; } = new("ServerSideSessions"); + /// /// Gets or sets a value indicating whether stale entries will be automatically cleaned up from the database. /// This is implemented by periodically connecting to the database (according to the TokenCleanupInterval) from the hosting application. @@ -61,14 +69,6 @@ public class OperationalStoreOptions: StoreOptions /// public TableConfiguration Keys { get; set; } = new("Keys"); - /// - /// Gets or sets the server-side sessions table configuration. - /// - /// - /// The server-side sessions' config. - /// - public TableConfiguration ServerSideSessions { get; set; } = new("ServerSideSessions"); - /// /// Gets or sets the pushed authorization requests table configuration. /// diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/Options/ServerSideSessionsOptions.cs index e9c0b12e5..22aab09a5 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. +using System; + namespace Open.IdentityServer.Configuration; /// @@ -14,4 +16,37 @@ public class ServerSideSessionsOptions /// Client.CoordinateLifetimeWithUserSession. /// public bool ExpiredSessionsTriggerBackchannelLogout { get; set; } + + /// + /// Specifies if expired sessions should be cleaned up automatically by Open.IdentityServer + /// + /// + /// The default value is true + /// + public bool RemoveExpiredSessions { get; set; } = true; + + /// + /// Specifies the frequency with which expired sessions are looked for and removed + /// + /// + /// 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. + /// + /// + /// The default value is true + /// + public bool FuzzExpiredSessionsFrequency { get; set; } = true; + + /// + /// Specifies how many expired sessions should be removed in a single pass + /// + /// + /// The default value is 100 + /// + public int RemoveExpiredSessionsBatchSize { get; set; } = 100; } \ No newline at end of file From da9d2654a3b63b45d4e0a5a6907e1350028ab43d Mon Sep 17 00:00:00 2001 From: James Britton Date: Fri, 4 Sep 2026 15:51:03 +0100 Subject: [PATCH 33/59] feat: added method on session store to get and delete expired sessions --- .../IdentityServerServerSideSessionStore.cs | 18 +++ .../src/TokenCleanup/TokenCleanupService.cs | 6 +- ...entityServerServerSideSessionStoreTests.cs | 136 ++++++++++++++---- .../TokenCleanup/TokenCleanupTests.cs | 37 +++-- .../Stores/InMemory/InMemorySessionStore.cs | 19 +++ .../BuilderExtensions/AdditionalTests.cs | 5 + .../Stores/InMemorySessionStoreTests.cs | 94 ++++++++++++ .../IIdentityServerServerSideSessionStore.cs | 7 + 8 files changed, 273 insertions(+), 49 deletions(-) diff --git a/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs b/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs index e962521bf..d8f4d7eda 100644 --- a/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs +++ b/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs @@ -138,4 +138,22 @@ public async Task> FilterSessions( .ToListAsync()) .Select(x => x.ToModel()); } + + /// + public async Task> GetAndRemoveExpiredSessions(int batchSize = 100) + { + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + + var sessions = await dbContext.ServerSideSessions + .Where(x => x.Expires < DateTime.UtcNow) + .OrderBy(x => x.Expires) + .Take(batchSize) + .ToArrayAsync(); + + dbContext.ServerSideSessions.RemoveRange(sessions); + await dbContext.SaveChangesAsync(); + + return sessions.Select(x => x.ToModel()); + + } } \ No newline at end of file diff --git a/src/EntityFramework.Storage/src/TokenCleanup/TokenCleanupService.cs b/src/EntityFramework.Storage/src/TokenCleanup/TokenCleanupService.cs index dd3bdaee5..7d07e7b8f 100644 --- a/src/EntityFramework.Storage/src/TokenCleanup/TokenCleanupService.cs +++ b/src/EntityFramework.Storage/src/TokenCleanup/TokenCleanupService.cs @@ -1,7 +1,6 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. - using System; using System.Linq; using System.Threading.Tasks; @@ -13,7 +12,7 @@ namespace Open.IdentityServer.EntityFramework; /// -/// Helper to cleanup stale persisted grants and device codes. +/// Helper to clean up stale persisted grants and device codes. /// public class TokenCleanupService { @@ -94,8 +93,7 @@ protected virtual async Task RemoveGrantsAsync() } } } - - + /// /// Removes the stale device codes. /// diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs index 80146b937..25cd2151d 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs @@ -39,7 +39,7 @@ private IdentityServerServerSideSessionStore CreateSut(PersistedGrantDbContext d [InlineData(" ")] public async Task GetSession_WhenKeyNullOrEmpty_ShouldThrowArgumentException(string key) { - await using var context = await CreateCleanContext(TestDatabaseProviders.FirstOrDefault()); + await using PersistedGrantDbContext context = await CreateCleanContext(TestDatabaseProviders.FirstOrDefault()); IdentityServerServerSideSessionStore sut = CreateSut(context); Func act = async () => await sut.GetSession(key); @@ -50,7 +50,7 @@ public async Task GetSession_WhenKeyNullOrEmpty_ShouldThrowArgumentException(str [Theory, MemberData(nameof(TestDatabaseProviders))] public async Task GetSession_WhenDoesntExist_ShouldReturnNull(DbContextOptions options) { - await using var context = await CreateCleanContext(options); + await using PersistedGrantDbContext context = await CreateCleanContext(options); IdentityServerServerSideSessionStore sut = CreateSut(context); SessionModel result = await sut.GetSession("missing-key"); @@ -61,7 +61,7 @@ public async Task GetSession_WhenDoesntExist_ShouldReturnNull(DbContextOptions

options) { - await using var context = await CreateCleanContext(options); + await using PersistedGrantDbContext context = await CreateCleanContext(options); string key = "session-key-1"; IdentityServerServerSideSessions seeded = new IdentityServerServerSideSessions @@ -102,10 +102,10 @@ public async Task GetSession_WhenExist_ShouldReturnValue(DbContextOptions act = async () => await sut.CreateSession(newSession); @@ -115,7 +115,7 @@ public async Task CreateSession_WhenKeyNullOrEmpty_ShouldThrowArgumentException( [Theory, MemberData(nameof(TestDatabaseProviders))] public async Task CreateSession_WhenSessionAlreadyExistsWithKey_ShouldLogError(DbContextOptions options) { - await using var context = await CreateCleanContext(options); + await using PersistedGrantDbContext context = await CreateCleanContext(options); string key = "duplicate-key"; context.ServerSideSessions.Add(new IdentityServerServerSideSessions @@ -133,7 +133,7 @@ public async Task CreateSession_WhenSessionAlreadyExistsWithKey_ShouldLogError(D await context.SaveChangesAsync(); IdentityServerServerSideSessionStore sut = CreateSut(context); - var newSession = BuildSessionModel(key, "sub-new", "sid-new", "new"); + SessionModel newSession = BuildSessionModel(key, "sub-new", "sid-new", "new"); await sut.CreateSession(newSession); @@ -143,16 +143,16 @@ public async Task CreateSession_WhenSessionAlreadyExistsWithKey_ShouldLogError(D [Theory, MemberData(nameof(TestDatabaseProviders))] public async Task CreateSession_WhenSessionDoesntExistsWithKey_ShouldStoreSessionInDatabase(DbContextOptions options) { - await using var context = await CreateCleanContext(options); + await using PersistedGrantDbContext context = await CreateCleanContext(options); string key = "new-key"; - var session = BuildSessionModel(key, "sub-123", "sid-123", "display-123"); + SessionModel session = BuildSessionModel(key, "sub-123", "sid-123", "display-123"); IdentityServerServerSideSessionStore sut = CreateSut(context); await sut.CreateSession(session); - var stored = await context.ServerSideSessions + IdentityServerServerSideSessions stored = await context.ServerSideSessions .AsNoTracking() .SingleOrDefaultAsync(x => x.Key == key, cancellationToken: TestContext.Current.CancellationToken); @@ -174,10 +174,10 @@ public async Task CreateSession_WhenSessionDoesntExistsWithKey_ShouldStoreSessio [InlineData(" ")] public async Task UpdateSession_WhenKeyNullOrEmpty_ShouldThrowArgumentException(string key) { - await using var context = await CreateCleanContext(TestDatabaseProviders.FirstOrDefault()); + await using PersistedGrantDbContext context = await CreateCleanContext(TestDatabaseProviders.FirstOrDefault()); IdentityServerServerSideSessionStore sut = CreateSut(context); - var session = BuildSessionModel(key, "sub-new", "sid-new", "new"); + SessionModel session = BuildSessionModel(key, "sub-new", "sid-new", "new"); Func act = async () => await sut.UpdateSession(session); @@ -187,10 +187,10 @@ public async Task UpdateSession_WhenKeyNullOrEmpty_ShouldThrowArgumentException( [Theory, MemberData(nameof(TestDatabaseProviders))] public async Task UpdateSession_WhenSessionDoesntExistsWithKey_ShouldLogError(DbContextOptions options) { - await using var context = await CreateCleanContext(options); + await using PersistedGrantDbContext context = await CreateCleanContext(options); IdentityServerServerSideSessionStore sut = CreateSut(context); - var session = BuildSessionModel("missing-update-key", "sub", "sid", "display"); + SessionModel session = BuildSessionModel("missing-update-key", "sub", "sid", "display"); await sut.UpdateSession(session); @@ -200,7 +200,7 @@ public async Task UpdateSession_WhenSessionDoesntExistsWithKey_ShouldLogError(Db [Theory, MemberData(nameof(TestDatabaseProviders))] public async Task UpdateSession_WhenSessionExistsWithKey_ShouldUpdateStoredSession(DbContextOptions options) { - await using var context = await CreateCleanContext(options); + await using PersistedGrantDbContext context = await CreateCleanContext(options); string key = "update-key"; context.ServerSideSessions.Add(new IdentityServerServerSideSessions @@ -217,7 +217,7 @@ public async Task UpdateSession_WhenSessionExistsWithKey_ShouldUpdateStoredSessi }); await context.SaveChangesAsync(); - var updated = BuildSessionModel(key, "new-sub", "new-sid", "new-display"); + SessionModel updated = BuildSessionModel(key, "new-sub", "new-sid", "new-display"); updated.Scheme = "new-scheme"; updated.Data = "{\"version\":2}"; updated.Created = DateTime.UtcNow.AddHours(-3); @@ -228,7 +228,7 @@ public async Task UpdateSession_WhenSessionExistsWithKey_ShouldUpdateStoredSessi await sut.UpdateSession(updated); - var stored = await context.ServerSideSessions + IdentityServerServerSideSessions stored = await context.ServerSideSessions .AsNoTracking() .SingleAsync(x => x.Key == key, cancellationToken: TestContext.Current.CancellationToken); @@ -249,7 +249,7 @@ public async Task UpdateSession_WhenSessionExistsWithKey_ShouldUpdateStoredSessi [InlineData(" ")] public async Task DeleteSession_WhenKeyNullOrEmpty_ShouldThrowArgumentException(string key) { - await using var context = await CreateCleanContext(TestDatabaseProviders.FirstOrDefault()); + await using PersistedGrantDbContext context = await CreateCleanContext(TestDatabaseProviders.FirstOrDefault()); IdentityServerServerSideSessionStore sut = CreateSut(context); Func act = async () => await sut.DeleteSession(key); @@ -260,7 +260,7 @@ public async Task DeleteSession_WhenKeyNullOrEmpty_ShouldThrowArgumentException( [Theory, MemberData(nameof(TestDatabaseProviders))] public async Task DeleteSession_WhenSessionDoesntExistsWithKey_ShouldLogError(DbContextOptions options) { - await using var context = await CreateCleanContext(options); + await using PersistedGrantDbContext context = await CreateCleanContext(options); IdentityServerServerSideSessionStore sut = CreateSut(context); await sut.DeleteSession("missing-delete-key"); @@ -271,7 +271,7 @@ public async Task DeleteSession_WhenSessionDoesntExistsWithKey_ShouldLogError(Db [Theory, MemberData(nameof(TestDatabaseProviders))] public async Task DeleteSession_WhenSessionExistsWithKey_ShouldDeleteStoredSession(DbContextOptions options) { - await using var context = await CreateCleanContext(options); + await using PersistedGrantDbContext context = await CreateCleanContext(options); string key = "delete-key"; context.ServerSideSessions.Add(new IdentityServerServerSideSessions @@ -292,7 +292,7 @@ public async Task DeleteSession_WhenSessionExistsWithKey_ShouldDeleteStoredSessi await sut.DeleteSession(key); - var stored = await context.ServerSideSessions + IdentityServerServerSideSessions stored = await context.ServerSideSessions .AsNoTracking() .SingleOrDefaultAsync(x => x.Key == key, cancellationToken: TestContext.Current.CancellationToken); @@ -344,6 +344,68 @@ await context.ServerSideSessions.AddRangeAsync([ actual.Should().Contain(x => x.Key == "key-1"); actual.Should().Contain(x => x.Key == "key-6"); } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task GetAndRemoveExpiredSessions_WhenNoExpiredSessionsExist_ShouldRemoveNothingAndReturnEmptyCollection(DbContextOptions options) + { + await using PersistedGrantDbContext context = await CreateCleanContext(options); + + IdentityServerServerSideSessions validSession0 = FakeSessionSession("123", "session1"); + IdentityServerServerSideSessions validSession1 = FakeSessionSession("456", "session2"); + context.ServerSideSessions.Add(validSession0); + context.ServerSideSessions.Add(validSession1); + await context.SaveChangesAsync(); + + IdentityServerServerSideSessionStore sut = CreateSut(context); + + List actual = (await sut.GetAndRemoveExpiredSessions()).ToList(); + + actual.Should().BeEmpty(); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task GetAndRemoveExpiredSessions_WhenExpiredSessionsExist_AndUnderBatchSize_ShouldDeleteExpiredSessionsAndReturnACollectionContainingRemovedSessions(DbContextOptions options) + { + await using PersistedGrantDbContext context = await CreateCleanContext(options); + + IdentityServerServerSideSessions expiredSession0 = FakeSessionSession("123", "session1", true); + IdentityServerServerSideSessions expiredSession1 = FakeSessionSession("456", "session2", true); + context.ServerSideSessions.Add(expiredSession0); + context.ServerSideSessions.Add(expiredSession1); + await context.SaveChangesAsync(); + + IdentityServerServerSideSessionStore sut = CreateSut(context); + + List actual = (await sut.GetAndRemoveExpiredSessions()).ToList(); + + actual.Should().HaveCount(2); + actual.Should().Contain(x => x.Key == expiredSession0.Key); + actual.Should().Contain(x => x.Key == expiredSession1.Key); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task GetAndRemoveExpiredSessions_WhenExpiredSessionsExist_AndExceedBatchSize_ShouldDeleteAndReturnExpiredSessions_WithACountOfBatchSize(DbContextOptions options) + { + await using PersistedGrantDbContext context = await CreateCleanContext(options); + + IdentityServerServerSideSessions expiredSession0 = FakeSessionSession("123", "session1", true); + IdentityServerServerSideSessions expiredSession1 = FakeSessionSession("456", "session2", true); + IdentityServerServerSideSessions expiredSession2 = FakeSessionSession("789", "session3", true); + IdentityServerServerSideSessions validSession0 = FakeSessionSession("234", "session4"); + context.ServerSideSessions.Add(expiredSession0); + context.ServerSideSessions.Add(expiredSession1); + context.ServerSideSessions.Add(expiredSession2); + context.ServerSideSessions.Add(validSession0); + await context.SaveChangesAsync(); + + IdentityServerServerSideSessionStore sut = CreateSut(context); + + List actual = (await sut.GetAndRemoveExpiredSessions(2)).ToList(); + + actual.Should().HaveCount(2); + actual.Should().Contain(x => x.Key == expiredSession0.Key); + actual.Should().Contain(x => x.Key == expiredSession1.Key); + } [Theory, MemberData(nameof(TestDatabaseProviders))] public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace(DbContextOptions options) @@ -355,11 +417,12 @@ 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.GetAndRemoveExpiredSessions(), "GetAndRemoveExpiredSessions"), ]; - foreach (var method in methods) + foreach ((Func actMethod, string traceMethodName) method in methods) { - var trace = Mock.Of(); + 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); @@ -367,7 +430,7 @@ public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace(DbContextOptions await using PersistedGrantDbContext context = new PersistedGrantDbContext(options, StoreOptions); - var store = CreateSut(context); + IdentityServerServerSideSessionStore store = CreateSut(context); await method.actMethod(store); @@ -409,4 +472,29 @@ private static SessionModel BuildSessionModel(string key, string subjectId, stri Data = "{\"payload\":\"value\"}" }; } + + private static IdentityServerServerSideSessions FakeSessionSession(string subject, string sessionId, bool expired = false) + { + IdentityServerServerSideSessions session = new IdentityServerServerSideSessions + { + Key = Guid.NewGuid().ToString(), + Scheme = Guid.NewGuid().ToString(), + SubjectId = subject, + SessionId = sessionId, + DisplayName = "user" + subject, + Created = DateTime.UtcNow.AddDays(-3), + Renewed = DateTime.UtcNow.AddDays(-3), + Expires = DateTime.UtcNow.AddDays(2), + Data = "{!}" + }; + + if (expired) + { + session.Created = DateTime.UtcNow.AddDays(-5); + session.Renewed = DateTime.UtcNow.AddDays(-4); + session.Expires = DateTime.UtcNow.AddDays(-3); + } + + return session; + } } \ No newline at end of file diff --git a/src/EntityFramework.Storage/test/IntegrationTests/TokenCleanup/TokenCleanupTests.cs b/src/EntityFramework.Storage/test/IntegrationTests/TokenCleanup/TokenCleanupTests.cs index 76e74f943..2f813f172 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/TokenCleanup/TokenCleanupTests.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/TokenCleanup/TokenCleanupTests.cs @@ -13,7 +13,6 @@ using Open.IdentityServer.EntityFramework.Options; using Open.IdentityServer.EntityFramework.Stores; using Open.IdentityServer.Stores; -using Open.IdentityServer.Test; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -44,15 +43,15 @@ public async Task RemoveExpiredGrantsAsync_WhenExpiredGrantsExist_ExpectExpiredG Data = "{!}" }; - using (var context = new PersistedGrantDbContext(options, StoreOptions)) + await using (var context = new PersistedGrantDbContext(options, StoreOptions)) { context.PersistedGrants.Add(expiredGrant); - context.SaveChanges(); + await context.SaveChangesAsync(); } await CreateSut(options).RemoveExpiredGrantsAsync(); - using (var context = new PersistedGrantDbContext(options, StoreOptions)) + await using (var context = new PersistedGrantDbContext(options, StoreOptions)) { context.PersistedGrants.FirstOrDefault(x => x.Key == expiredGrant.Key).Should().BeNull(); } @@ -71,15 +70,15 @@ public async Task RemoveExpiredGrantsAsync_WhenValidGrantsExist_ExpectValidGrant Data = "{!}" }; - using (var context = new PersistedGrantDbContext(options, StoreOptions)) + await using (var context = new PersistedGrantDbContext(options, StoreOptions)) { context.PersistedGrants.Add(validGrant); - context.SaveChanges(); + await context.SaveChangesAsync(); } await CreateSut(options).RemoveExpiredGrantsAsync(); - using (var context = new PersistedGrantDbContext(options, StoreOptions)) + await using (var context = new PersistedGrantDbContext(options, StoreOptions)) { context.PersistedGrants.FirstOrDefault(x => x.Key == validGrant.Key).Should().NotBeNull(); } @@ -99,15 +98,15 @@ public async Task RemoveExpiredGrantsAsync_WhenExpiredDeviceGrantsExist_ExpectEx Data = "{!}" }; - using (var context = new PersistedGrantDbContext(options, StoreOptions)) + await using (var context = new PersistedGrantDbContext(options, StoreOptions)) { context.DeviceFlowCodes.Add(expiredGrant); - context.SaveChanges(); + await context.SaveChangesAsync(); } await CreateSut(options).RemoveExpiredGrantsAsync(); - using (var context = new PersistedGrantDbContext(options, StoreOptions)) + await using (var context = new PersistedGrantDbContext(options, StoreOptions)) { context.DeviceFlowCodes.FirstOrDefault(x => x.DeviceCode == expiredGrant.DeviceCode).Should().BeNull(); } @@ -127,25 +126,25 @@ public async Task RemoveExpiredGrantsAsync_WhenValidDeviceGrantsExist_ExpectVali Data = "{!}" }; - using (var context = new PersistedGrantDbContext(options, StoreOptions)) + await using (var context = new PersistedGrantDbContext(options, StoreOptions)) { context.DeviceFlowCodes.Add(validGrant); - context.SaveChanges(); + await context.SaveChangesAsync(); } await CreateSut(options).RemoveExpiredGrantsAsync(); - using (var context = new PersistedGrantDbContext(options, StoreOptions)) + await using (var context = new PersistedGrantDbContext(options, StoreOptions)) { context.DeviceFlowCodes.FirstOrDefault(x => x.DeviceCode == validGrant.DeviceCode).Should().NotBeNull(); } } - private EntityFramework.TokenCleanupService CreateSut(DbContextOptions options) + private TokenCleanupService CreateSut(DbContextOptions options) { IServiceCollection services = new ServiceCollection(); services.AddIdentityServer() - .AddTestUsers(new List()) + .AddTestUsers([]) .AddInMemoryClients(new List()) .AddInMemoryIdentityResources(new List()) .AddInMemoryApiResources(new List()); @@ -155,13 +154,9 @@ private EntityFramework.TokenCleanupService CreateSut(DbContextOptions(); services.AddTransient(); - services.AddTransient(); + services.AddTransient(); services.AddSingleton(StoreOptions); - return services.BuildServiceProvider().GetRequiredService(); - //return new EntityFramework.TokenCleanupService( - // services.BuildServiceProvider(), - // new NullLogger(), - // StoreOptions); + return services.BuildServiceProvider().GetRequiredService(); } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs b/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs index 03ee23881..da96bbb8f 100644 --- a/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs +++ b/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs @@ -3,6 +3,7 @@ #nullable enable +using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; @@ -52,4 +53,22 @@ public Task> FilterSessions(string return Task.FromResult(repo.Values .Where(x => x.SubjectId == subjectId && x.SessionId == sessionId)); } + + /// + public Task> GetAndRemoveExpiredSessions(int batchSize = 100) + { + IEnumerable sessions = repo + .Select(x => x.Value) + .Where(x => x.Expires < DateTime.UtcNow) + .OrderBy(x => x.Expires) + .Take(batchSize) + .ToList(); + + foreach (var session in sessions) + { + repo.TryRemove(session.Key, out _); + } + + return Task.FromResult(sessions); + } } \ No newline at end of file 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 216f537b7..f7e2184c7 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 @@ -138,4 +138,9 @@ public Task> FilterSessions(string { throw new System.NotImplementedException(); } + + public Task> GetAndRemoveExpiredSessions(int batchSize = 100) + { + throw new System.NotImplementedException(); + } } \ No newline at end of file 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 2fd90f320..ab710593e 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemorySessionStoreTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemorySessionStoreTests.cs @@ -259,4 +259,98 @@ public async Task FilterSessions_WhenSessionMatch_ShouldReturnMatchingSessions() actual.Should().Contain(x => x.Key == "key-1"); actual.Should().Contain(x => x.Key == "key-6"); } + + [Fact] + public async Task GetAndRemoveExpiredSessions_WhenNoExpiredSessionsExist_ShouldRemoveNothingAndReturnEmptyCollection() + { + IdentityServerServerSideSessions validSession0 = FakeSessionSession("123", "session1"); + IdentityServerServerSideSessions validSession1 = FakeSessionSession("456", "session2"); + + InMemorySessionStore sut = CreateSut([validSession0, validSession1]); + + (await sut.GetSession(validSession0.Key)).Should().NotBeNull(); + (await sut.GetSession(validSession1.Key)).Should().NotBeNull(); + + List actual = (await sut.GetAndRemoveExpiredSessions()).ToList(); + + actual.Should().BeEmpty(); + + (await sut.GetSession(validSession0.Key)).Should().NotBeNull(); + (await sut.GetSession(validSession1.Key)).Should().NotBeNull(); + } + + [Fact] + public async Task GetAndRemoveExpiredSessions_WhenExpiredSessionsExist_AndUnderBatchSize_ShouldDeleteExpiredSessionsAndReturnACollectionContainingRemovedSessions() + { + IdentityServerServerSideSessions expiredSession0 = FakeSessionSession("123", "session1", true); + IdentityServerServerSideSessions expiredSession1 = FakeSessionSession("456", "session2", true); + + InMemorySessionStore sut = CreateSut([expiredSession0, expiredSession1]); + + (await sut.GetSession(expiredSession0.Key)).Should().NotBeNull(); + (await sut.GetSession(expiredSession1.Key)).Should().NotBeNull(); + + List actual = (await sut.GetAndRemoveExpiredSessions()).ToList(); + + actual.Should().HaveCount(2); + actual.Should().Contain(x => x.Key == expiredSession0.Key); + actual.Should().Contain(x => x.Key == expiredSession1.Key); + + (await sut.GetSession(expiredSession0.Key)).Should().BeNull(); + (await sut.GetSession(expiredSession1.Key)).Should().BeNull(); + } + + [Fact] + public async Task GetAndRemoveExpiredSessions_WhenExpiredSessionsExist_AndExceedBatchSize_ShouldDeleteAndReturnExpiredSessions_WithACountOfBatchSize() + { + IdentityServerServerSideSessions expiredSession0 = FakeSessionSession("123", "session1", true); + IdentityServerServerSideSessions expiredSession1 = FakeSessionSession("456", "session2", true); + IdentityServerServerSideSessions expiredSession2 = FakeSessionSession("789", "session3", true); + IdentityServerServerSideSessions validSession0 = FakeSessionSession("234", "session4"); + + InMemorySessionStore sut = CreateSut([expiredSession0, expiredSession1, expiredSession2, validSession0]); + + (await sut.GetSession(expiredSession0.Key)).Should().NotBeNull(); + (await sut.GetSession(expiredSession1.Key)).Should().NotBeNull(); + (await sut.GetSession(expiredSession2.Key)).Should().NotBeNull(); + (await sut.GetSession(validSession0.Key)).Should().NotBeNull(); + + List actual = (await sut.GetAndRemoveExpiredSessions(2)).ToList(); + + actual.Should().HaveCount(2); + actual.Should().Contain(x => x.Key == expiredSession0.Key); + actual.Should().Contain(x => x.Key == expiredSession1.Key); + actual.Should().NotContain(x => x.Key == expiredSession2.Key); + actual.Should().NotContain(x => x.Key == validSession0.Key); + + (await sut.GetSession(expiredSession0.Key)).Should().BeNull(); + (await sut.GetSession(expiredSession1.Key)).Should().BeNull(); + (await sut.GetSession(expiredSession2.Key)).Should().NotBeNull(); + (await sut.GetSession(validSession0.Key)).Should().NotBeNull(); + } + + private static IdentityServerServerSideSessions FakeSessionSession(string subject, string sessionId, bool expired = false) + { + IdentityServerServerSideSessions session = new IdentityServerServerSideSessions + { + Key = Guid.NewGuid().ToString(), + Scheme = Guid.NewGuid().ToString(), + SubjectId = subject, + SessionId = sessionId, + DisplayName = "user" + subject, + Created = DateTime.UtcNow.AddDays(-3), + Renewed = DateTime.UtcNow.AddDays(-3), + Expires = DateTime.UtcNow.AddDays(2), + Data = "{!}" + }; + + if (expired) + { + session.Created = DateTime.UtcNow.AddDays(-5); + session.Renewed = DateTime.UtcNow.AddDays(-4); + session.Expires = DateTime.UtcNow.AddDays(-3); + } + + return session; + } } \ No newline at end of file diff --git a/src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs b/src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs index 69f620bb7..53280814b 100644 --- a/src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs +++ b/src/Storage/src/Stores/Compatibility/IIdentityServerServerSideSessionStore.cs @@ -49,4 +49,11 @@ public interface IIdentityServerServerSideSessionStore /// session id filter to apply /// collection of session entities matching filter public Task> FilterSessions(string subjectId, string sessionId); + + /// + /// Removes expired sessions and returns a collection of sessions that were removed + /// + /// optional batch size value, defaults to 100 + /// removed expired sessions + public Task> GetAndRemoveExpiredSessions(int batchSize = 100); } \ No newline at end of file From 49335c6da9c58659d744441a1a7dac5902ecc610 Mon Sep 17 00:00:00 2001 From: James Britton Date: Fri, 4 Sep 2026 16:06:50 +0100 Subject: [PATCH 34/59] feat: implement helper service for running the process of removing expired tokens and triggering notifications --- .../Services/Default/SessionCleanupService.cs | 83 +++++++ .../Default/ServerSessionTicketStore.cs | 14 ++ .../src/Stores/IServerSessionTicketStore.cs | 7 + .../Default/SessionCleanupServiceTests.cs | 226 ++++++++++++++++++ .../Default/ServerSessionTicketStoreTests.cs | 50 +++- 5 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 src/Open.IdentityServer/src/Services/Default/SessionCleanupService.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/SessionCleanupServiceTests.cs diff --git a/src/Open.IdentityServer/src/Services/Default/SessionCleanupService.cs b/src/Open.IdentityServer/src/Services/Default/SessionCleanupService.cs new file mode 100644 index 000000000..745aeeeaa --- /dev/null +++ b/src/Open.IdentityServer/src/Services/Default/SessionCleanupService.cs @@ -0,0 +1,83 @@ +// 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.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.Models; +using Open.IdentityServer.Services; +using Open.IdentityServer.Stores; + +namespace Open.IdentityServer.EntityFramework; + +/// +/// Helper to clean up expired server-side sessions +/// +/// IdentityServer options +/// server side sessions store +/// user session events service +/// telemetry service +/// logger +public class SessionCleanupService( + IdentityServerOptions options, + IServerSessionTicketStore serverSideSessionStore, + IUserSessionEventsService userSessionEventsService, + ITelemetryService telemetry, + ILogger logger) +{ + /// + /// Method to clear expired server-side sessions. + /// + /// A that completes once all expired grants and device codes have been removed. + public async Task RemoveExpiredServerSideSessionsAsync() + { + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Services, this); + + try + { + logger.LogTrace("Querying for expired sessions to remove"); + + await RemoveServerSideSessionsAsync(); + } + catch (Exception ex) + { + logger.LogError("Exception removing expired sessions: {Exception}", ex.Message); + } + } + + /// + /// Removes the expired sessions. + /// + /// A that completes once all batches of expired sessions have been deleted. + private async Task RemoveServerSideSessionsAsync() + { + var found = Int32.MaxValue; + + while (found >= options.ServerSideSessions.RemoveExpiredSessionsBatchSize) + { + var expiredSessions = (await serverSideSessionStore + .GetAndRemoveExpiredSessions(options.ServerSideSessions.RemoveExpiredSessionsBatchSize)) + .ToList(); + + found = expiredSessions.Count; + logger.LogInformation("Removed {ExpiredSessionsCount} expired server side sessions", found); + + if (found > 0) + { + foreach (var expiredSession in expiredSessions) + { + // TODO, finish implementing, should get auth ticket so clientIds can be populated + await userSessionEventsService.HandleUserSessionExpiry(new EndUserSessionEventContext() + { + SubjectId = expiredSession.Session.SubjectId, + SessionId = expiredSession.Session.SessionId, + ClientIds = expiredSession.AuthTicket?.Properties.GetClientList().ToArray() ?? [], + }); + } + } + } + } +} \ 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 333854a3c..7c5d5ddb9 100644 --- a/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs +++ b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs @@ -143,6 +143,20 @@ public async Task> FilterServerAut }).Where(x => x.AuthTicket != null); } + /// + public async Task> GetAndRemoveExpiredSessions(int batchSize = 100) + { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + + var sessionRemoved = await serverServerSideSessionStore.GetAndRemoveExpiredSessions(batchSize); + + return sessionRemoved.Select(x => new AuthenticationTicketFilterResult + { + Session = x, + AuthTicket = DeserializeAuthTicket(x), + }).Where(x => x.AuthTicket != null); + } + private async Task StoreNewSession(string key, AuthenticationTicket ticket) { IdentityServerServerSideSessions serverSideSession = new IdentityServerServerSideSessions diff --git a/src/Open.IdentityServer/src/Stores/IServerSessionTicketStore.cs b/src/Open.IdentityServer/src/Stores/IServerSessionTicketStore.cs index 0a68cede9..e65f9f8bb 100644 --- a/src/Open.IdentityServer/src/Stores/IServerSessionTicketStore.cs +++ b/src/Open.IdentityServer/src/Stores/IServerSessionTicketStore.cs @@ -21,4 +21,11 @@ public interface IServerSessionTicketStore: ITicketStore /// session id filter to apply /// collection of auth ticket matching filter Task> FilterServerAuthenticationTickets(string subjectId, string sessionId); + + /// + /// Removes expired auth tickets and returns a collection of these auth tokens and session objects they come from + /// + /// optional batch size value, defaults to 100 + /// removed expired sessions + public Task> GetAndRemoveExpiredSessions(int batchSize = 100); } \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/SessionCleanupServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/SessionCleanupServiceTests.cs new file mode 100644 index 000000000..b47522027 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/SessionCleanupServiceTests.cs @@ -0,0 +1,226 @@ +// 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 System.Linq; +using System.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Moq; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.EntityFramework; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.Models; +using Open.IdentityServer.Services; +using Open.IdentityServer.Stores; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Services; + +public class SessionCleanupServiceTests +{ + private IdentityServerOptions options = new(); + + private IServerSessionTicketStore serverSideSessionStore = + Mock.Of(); + + private IUserSessionEventsService userSessionEventsService = Mock.Of(); + private ITelemetryService telemetry = Mock.Of(); + private ILogger logger = Mock.Of>(); + + private SessionCleanupService CreateSut() => + new(options, serverSideSessionStore, userSessionEventsService, telemetry, logger); + + [Fact] + public async Task + RemoveExpiredServerSideSessionsAsync_WhenExpiredServerSideSessionExist_ExpectExpiredDeviceGrantsRemoved() + { + var expiredSession = FakeSessionSession("123", "sesh1", true); + + Mock.Get(serverSideSessionStore) + .SetupSequence(x => + x.GetAndRemoveExpiredSessions(options.ServerSideSessions.RemoveExpiredSessionsBatchSize)) + .ReturnsAsync([expiredSession]); + + var sut = CreateSut(); + + await sut.RemoveExpiredServerSideSessionsAsync(); + + Mock.Get(serverSideSessionStore) + .Verify(x => x.GetAndRemoveExpiredSessions(options.ServerSideSessions.RemoveExpiredSessionsBatchSize)); + + Mock.Get(userSessionEventsService) + .Verify(x => x.HandleUserSessionExpiry(It.IsAny()), Times.Once); + } + + [Fact] + public async Task RemoveExpiredServerSideSessionsAsync_WhenValidServerSideSessionExist_ExpectValidDeviceGrantsInDb() + { + Mock.Get(serverSideSessionStore) + .SetupSequence(x => + x.GetAndRemoveExpiredSessions(options.ServerSideSessions.RemoveExpiredSessionsBatchSize)) + .ReturnsAsync([]); + + var sut = CreateSut(); + + await sut.RemoveExpiredServerSideSessionsAsync(); + + Mock.Get(serverSideSessionStore) + .Verify(x => x.GetAndRemoveExpiredSessions(options.ServerSideSessions.RemoveExpiredSessionsBatchSize)); + + Mock.Get(userSessionEventsService) + .Verify(x => x.HandleUserSessionExpiry(It.IsAny()), Times.Never); + } + + [Fact] + public async Task + RemoveExpiredServerSideSessionsAsync_WhenMultipleExpiredServerSideSessionExist_ExpectExpiredDeviceGrantsRemoved() + { + options.ServerSideSessions.RemoveExpiredSessionsBatchSize = 2; + + var expiredSession0 = FakeSessionSession("123", "sesh1", true); + var expiredSession1 = FakeSessionSession("456", "sesh2", true); + var expiredSession2 = FakeSessionSession("789", "sesh3", true); + + Mock.Get(serverSideSessionStore) + .SetupSequence(x => + x.GetAndRemoveExpiredSessions(options.ServerSideSessions.RemoveExpiredSessionsBatchSize)) + .ReturnsAsync([expiredSession0, expiredSession1]) + .ReturnsAsync([expiredSession2]); + + var sut = CreateSut(); + + await sut.RemoveExpiredServerSideSessionsAsync(); + + Mock.Get(serverSideSessionStore) + .Verify(x => x.GetAndRemoveExpiredSessions(options.ServerSideSessions.RemoveExpiredSessionsBatchSize), + Times.Exactly(2)); + + Mock.Get(userSessionEventsService) + .Verify(x => x.HandleUserSessionExpiry(It.IsAny()), Times.Exactly(3)); + } + + [Fact] + public async Task RemoveExpiredServerSideSessionsAsync_WhenExpiredSessionContainsClientList_ExpectExpiredDeviceGrantsRemoved() + { + string[] fakeClientIds = ["client1", "client2"]; + var expiredSession0 = FakeSessionSession("123", "sesh1", true, fakeClientIds); + + Mock.Get(serverSideSessionStore) + .SetupSequence(x => + x.GetAndRemoveExpiredSessions(options.ServerSideSessions.RemoveExpiredSessionsBatchSize)) + .ReturnsAsync([expiredSession0]); + + EndUserSessionEventContext? context = null; + Mock.Get(userSessionEventsService) + .Setup(x => x.HandleUserSessionExpiry(It.IsAny())) + .Callback(x => context = x); + + var sut = CreateSut(); + + await sut.RemoveExpiredServerSideSessionsAsync(); + + Mock.Get(serverSideSessionStore) + .Verify(x => x.GetAndRemoveExpiredSessions(options.ServerSideSessions.RemoveExpiredSessionsBatchSize), + Times.Once); + + Mock.Get(userSessionEventsService) + .Verify(x => x.HandleUserSessionExpiry(It.IsAny()), Times.Once); + + context.Should().NotBeNull(); + context.SessionId.Should().Be(expiredSession0.Session.SessionId); + context.SubjectId.Should().Be(expiredSession0.Session.SubjectId); + context.ClientIds.Should().BeEquivalentTo(fakeClientIds); + } + + [Fact] + public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace() + { + List<(Func actMethod, string traceMethodName)> methods = + [ + (store => store.RemoveExpiredServerSideSessionsAsync(), "RemoveExpiredServerSideSessionsAsync") + ]; + + 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); + + SessionCleanupService store = CreateSut(); + + await method.actMethod(store); + + Mock.Get(telemetry) + .Verify(t => t.Trace( + TelemetryConstants.TraceCategories.Services, store, method.traceMethodName), Times.Once); + Mock.Get(trace).Verify(t => t.Dispose(), Times.Once); + } + + // Assert all methods covered + typeof(SessionCleanupService).GetMethods() + .Where(m => m is { IsPublic: true, IsStatic: false, IsSpecialName: false }) + .Where(m => m.DeclaringType == typeof(SessionCleanupService)) + .Select(m => m.Name) + .Distinct() + .Should().BeEquivalentTo(methods.Select(m => m.traceMethodName)); + } + + private static AuthenticationTicketFilterResult FakeSessionSession(string subject, string sessionId, + bool expired = false, string[]? clientsIds = null) + { + var session = new IdentityServerServerSideSessions + { + Key = Guid.NewGuid().ToString(), + Scheme = Guid.NewGuid().ToString(), + SubjectId = subject, + SessionId = sessionId, + DisplayName = "user" + subject, + Created = DateTime.UtcNow.AddDays(-3), + Renewed = DateTime.UtcNow.AddDays(-3), + Expires = DateTime.UtcNow.AddDays(2), + Data = "{!}" + }; + + if (expired) + { + session.Created = DateTime.UtcNow.AddDays(-5); + session.Renewed = DateTime.UtcNow.AddDays(-4); + session.Expires = DateTime.UtcNow.AddDays(-3); + } + + return new AuthenticationTicketFilterResult + { + Session = session, + AuthTicket = GenerateAuthenticationTicket(session, clientsIds), + }; + } + + private static AuthenticationTicket GenerateAuthenticationTicket(IdentityServerServerSideSessions session, string[]? clientIds = null) + { + IdentityServerUser user = new(session.SubjectId); + AuthenticationProperties properties = new(); + + properties.SetSessionId(session.SessionId); + + user.DisplayName = session.DisplayName; + properties.IssuedUtc = session.Renewed; + properties.ExpiresUtc = session.Expires; + + if (clientIds != null) + { + foreach (var clientId in clientIds) + { + properties.AddClientId(clientId); + } + } + + return new AuthenticationTicket(user.CreatePrincipal(), properties, session.Scheme); + } +} \ 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 942ca1f0b..411b02786 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 @@ -452,7 +452,8 @@ public async Task FilterServerAuthenticationTickets_WhenSessionReturnedFromStore ]; List expectedAuthTickets = []; - sessions = sessions.Select(x => GenerateSerialisedData(expectedAuthTickets, x)); + sessions = sessions.Select(x => GenerateSerialisedData(expectedAuthTickets, x)) + .ToList(); Mock.Get(serverServerSideSessionStore) .Setup(x => x.FilterSessions(testSubjectId, testSessionId)) @@ -464,6 +465,52 @@ public async Task FilterServerAuthenticationTickets_WhenSessionReturnedFromStore actual.Should().NotBeNullOrEmpty(); actual.Should().HaveCount(expectedAuthTickets.Count); + + foreach (var session in sessions) + { + ValidateAutTicketExists(actual, session); + } + } + + [Fact] + public async Task GetAndRemoveExpiredSessions_WhenSessionReturnedFromStore_ShouldReturnExtractedAuthTickets() + { + 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"), + ]; + + List expectedAuthTickets = []; + sessions = sessions.Select(x => GenerateSerialisedData(expectedAuthTickets, x)) + .ToList(); + + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.GetAndRemoveExpiredSessions(batchSize)) + .ReturnsAsync(sessions); + + ServerSessionTicketStore sut = CreateSut(); + IEnumerable actual = + (await sut.GetAndRemoveExpiredSessions(batchSize)).ToList(); + + actual.Should().NotBeNullOrEmpty(); + actual.Should().HaveCount(expectedAuthTickets.Count); + + foreach (var session in sessions) + { + ValidateAutTicketExists(actual, session); + } + } + + private void ValidateAutTicketExists(IEnumerable result, IdentityServerServerSideSessions sesison) + { + result.Should().Contain(x => + x.AuthTicket != null && + x.AuthTicket.AuthenticationScheme == sesison.Scheme && + x.AuthTicket.Principal.GetSubjectId() == sesison.SubjectId && + x.AuthTicket.Properties.GetSessionId() == sesison.SessionId); } [Fact] @@ -479,6 +526,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.GetAndRemoveExpiredSessions(), "GetAndRemoveExpiredSessions"), ]; var sut = CreateSut(); From f61d47e497380a4a2b746e6f0980c66297e666f2 Mon Sep 17 00:00:00 2001 From: James Britton Date: Mon, 7 Sep 2026 15:56:26 +0100 Subject: [PATCH 35/59] feat: implement server-side session cleanup hosted service --- .../BuilderExtensions/Additional.cs | 5 + .../SessionCleanupHostedService.cs | 120 ++++++++++++++++++ .../Services/Default/SessionCleanupService.cs | 7 +- .../src/Services/ISessionCleanupService.cs | 15 +++ .../BuilderExtensions/AdditionalTests.cs | 21 +++ .../SessionCleanupHostedServiceTests.cs | 105 +++++++++++++++ .../Default/SessionCleanupServiceTests.cs | 1 + 7 files changed, 270 insertions(+), 4 deletions(-) create mode 100644 src/Open.IdentityServer/src/HostedServices/SessionCleanupHostedService.cs create mode 100644 src/Open.IdentityServer/src/Services/ISessionCleanupService.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/HostedServices/SessionCleanupHostedServiceTests.cs diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs index 5104e7768..2d0c43058 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/BuilderExtensions/Additional.cs @@ -11,6 +11,7 @@ using System; using System.Net.Http; using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.Hosting; using Open.IdentityServer; using Open.IdentityServer.Configuration; using Microsoft.Extensions.Logging; @@ -481,6 +482,10 @@ public static IIdentityServerBuilder AddServerSideSessions(this IIdentityServerB // provide default in-memory implementation, not suitable for most production scenarios (following pattern implemented with existing stores) builder.Services.TryAddSingleton(); + + //Clean-up Service + builder.Services.AddTransient(); + builder.Services.AddSingleton(); return builder; } diff --git a/src/Open.IdentityServer/src/HostedServices/SessionCleanupHostedService.cs b/src/Open.IdentityServer/src/HostedServices/SessionCleanupHostedService.cs new file mode 100644 index 000000000..c7563fc1a --- /dev/null +++ b/src/Open.IdentityServer/src/HostedServices/SessionCleanupHostedService.cs @@ -0,0 +1,120 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Services; + +namespace Open.IdentityServer; + +/// +/// A service for running server-side session clean-up periodically +/// +public class SessionCleanupHostedService( + IServiceProvider serviceProvider, + IdentityServerOptions options, + ILogger logger): IHostedService +{ + private CancellationTokenSource source; + + /// + /// Starts the hosted service if configured to be enabled + /// + /// + /// + public Task StartAsync(CancellationToken cancellationToken) + { + if (source != null) throw new InvalidOperationException("Already started. Call Stop first."); + + logger.LogDebug("Starting expired session removal"); + + source = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + Task.Factory.StartNew(() => StartInternalAsync(source.Token), cancellationToken); + + return Task.CompletedTask; + } + + /// + /// Stops the hosted service if started + /// + /// + /// + public Task StopAsync(CancellationToken cancellationToken) + { + if (source == null) throw new InvalidOperationException("Not started. Call Start first."); + + logger.LogDebug("Stopping expired session removal"); + + source.Cancel(); + source = null; + + return Task.CompletedTask; + } + + private async Task StartInternalAsync(CancellationToken cancellationToken) + { + if (options.ServerSideSessions.FuzzExpiredSessionsFrequency) + { + Random rnd = new Random(); + int seconds = rnd.Next(1, 120); + logger.LogDebug("Fuzzing session cleanup service start time by {Seconds}", seconds); + await Task.Delay(TimeSpan.FromSeconds(seconds), cancellationToken); + } + + while (true) + { + if (cancellationToken.IsCancellationRequested) + { + logger.LogDebug("CancellationRequested. Exiting"); + break; + } + + try + { + await Task.Delay(options.ServerSideSessions.RemoveExpiredSessionsFrequency, cancellationToken); + } + catch (TaskCanceledException) + { + logger.LogDebug("TaskCanceledException. Exiting"); + break; + } + catch (Exception ex) + { + logger.LogError("Task.Delay exception: {ExceptionMsg}. Exiting", ex.Message); + break; + } + + if (cancellationToken.IsCancellationRequested) + { + logger.LogDebug("CancellationRequested. Exiting"); + break; + } + + if (options.ServerSideSessions.RemoveExpiredSessions) + { + await RemoveExpiredSessions(); + } + else + { + logger.LogDebug("Expired session removal disabled"); + } + } + } + + private async Task RemoveExpiredSessions() + { + try + { + using var serviceScope = serviceProvider.GetRequiredService().CreateScope(); + var tokenCleanupService = serviceScope.ServiceProvider.GetRequiredService(); + await tokenCleanupService.RemoveExpiredServerSideSessionsAsync(); + } + catch (Exception ex) + { + logger.LogError("Exception removing expired sessions: {ExceptionMsg}", ex.Message); + } + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Services/Default/SessionCleanupService.cs b/src/Open.IdentityServer/src/Services/Default/SessionCleanupService.cs index 745aeeeaa..bcc430e94 100644 --- a/src/Open.IdentityServer/src/Services/Default/SessionCleanupService.cs +++ b/src/Open.IdentityServer/src/Services/Default/SessionCleanupService.cs @@ -8,13 +8,12 @@ using Open.IdentityServer.Configuration; using Open.IdentityServer.Extensions; using Open.IdentityServer.Models; -using Open.IdentityServer.Services; using Open.IdentityServer.Stores; -namespace Open.IdentityServer.EntityFramework; +namespace Open.IdentityServer.Services; /// -/// Helper to clean up expired server-side sessions +/// Service to clean up expired server-side sessions /// /// IdentityServer options /// server side sessions store @@ -26,7 +25,7 @@ public class SessionCleanupService( IServerSessionTicketStore serverSideSessionStore, IUserSessionEventsService userSessionEventsService, ITelemetryService telemetry, - ILogger logger) + ILogger logger): ISessionCleanupService { /// /// Method to clear expired server-side sessions. diff --git a/src/Open.IdentityServer/src/Services/ISessionCleanupService.cs b/src/Open.IdentityServer/src/Services/ISessionCleanupService.cs new file mode 100644 index 000000000..9fbfa3711 --- /dev/null +++ b/src/Open.IdentityServer/src/Services/ISessionCleanupService.cs @@ -0,0 +1,15 @@ +using System.Threading.Tasks; + +namespace Open.IdentityServer.Services; + +/// +/// Service to clean up expired server-side sessions +/// +public interface ISessionCleanupService +{ + /// + /// Method to clear expired server-side sessions. + /// + /// A that completes once all expired grants and device codes have been removed. + public Task RemoveExpiredServerSideSessionsAsync(); +} \ No newline at end of file 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 f7e2184c7..2b49272fa 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 @@ -6,11 +6,14 @@ using AwesomeAssertions; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; using Open.IdentityServer.Configuration; using Open.IdentityServer.Configuration.DependencyInjection; +using Open.IdentityServer.EntityFramework; using Open.IdentityServer.Models; using Open.IdentityServer.Services; +using Open.IdentityServer.Services.Default; using Open.IdentityServer.Stores; using Open.IdentityServer.Validation; using Xunit; @@ -63,6 +66,15 @@ public void AddServerSideSessions_WhenNoStoreConfigured_ShouldConfigureServerSid d.ServiceType == typeof(IIdentityServerServerSideSessionStore) && d.ImplementationType == typeof(InMemorySessionStore) && d.Lifetime == ServiceLifetime.Singleton); + + serviceCollection.Should().ContainSingle(d => + d.ImplementationType == typeof(SessionCleanupService) && + d.Lifetime == ServiceLifetime.Transient); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(IHostedService) && + d.ImplementationType == typeof(SessionCleanupHostedService) && + d.Lifetime == ServiceLifetime.Singleton); } [Fact] @@ -109,6 +121,15 @@ public void AddServerSideSessions_WhenStoreConfigured_ShouldConfigureServerSideS d.ServiceType == typeof(IIdentityServerServerSideSessionStore) && d.ImplementationType == typeof(InMemorySessionStore) && d.Lifetime == ServiceLifetime.Singleton); + + serviceCollection.Should().ContainSingle(d => + d.ImplementationType == typeof(SessionCleanupService) && + d.Lifetime == ServiceLifetime.Transient); + + serviceCollection.Should().ContainSingle(d => + d.ServiceType == typeof(IHostedService) && + d.ImplementationType == typeof(SessionCleanupHostedService) && + d.Lifetime == ServiceLifetime.Singleton); } } diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/HostedServices/SessionCleanupHostedServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/HostedServices/SessionCleanupHostedServiceTests.cs new file mode 100644 index 000000000..93288f81f --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/HostedServices/SessionCleanupHostedServiceTests.cs @@ -0,0 +1,105 @@ +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Services; +using Xunit; +using Range = Moq.Range; + +namespace Open.IdentityServer.UnitTests.HostedServices; + +public class SessionCleanupHostedServiceTests +{ + private IServiceProvider serviceProvider = Mock.Of(); + private IdentityServerOptions options = new() + { + ServerSideSessions = new ServerSideSessionsOptions + { + RemoveExpiredSessions = true, + RemoveExpiredSessionsBatchSize = 100, + RemoveExpiredSessionsFrequency = TimeSpan.FromSeconds(0.25), + FuzzExpiredSessionsFrequency = false, + }, + }; + private ILogger logger = Mock.Of>(); + + private IServiceScopeFactory scopeFactory = Mock.Of(); + private IServiceScope serviceScope = Mock.Of(); + private ISessionCleanupService sessionCleanupService = Mock.Of(); + + public SessionCleanupHostedServiceTests() + { + Mock.Get(serviceProvider) + .Setup(x => x.GetService(typeof(IServiceScopeFactory))) + .Returns(scopeFactory); + + Mock.Get(serviceProvider) + .Setup(x => x.GetService(typeof(ISessionCleanupService))) + .Returns(sessionCleanupService); + + Mock.Get(scopeFactory) + .Setup(x => x.CreateScope()) + .Returns(serviceScope); + + Mock.Get(serviceScope) + .Setup(x => x.ServiceProvider) + .Returns(serviceProvider); + } + + private SessionCleanupHostedService CreateSut() => new(serviceProvider, options, logger); + + [Fact] + public async Task SessionCleanupHostedService_WhenCleanupDisabled_ShouldNeverTrigger() + { + options.ServerSideSessions.RemoveExpiredSessions = false; + + CancellationTokenSource ctSrc = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + + var sut = CreateSut(); + + await sut.StartAsync(ctSrc.Token); + await Task.Delay(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + await sut.StopAsync(ctSrc.Token); + + Mock.Get(sessionCleanupService) + .Verify(x => x.RemoveExpiredServerSideSessionsAsync(), Times.Never); + } + + [Fact] + public async Task SessionCleanupHostedService_WhenFuzzDisabled_ShouldTriggerAtLeast3Time() + { + CancellationTokenSource ctSrc = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + + var sut = CreateSut(); + + await sut.StartAsync(ctSrc.Token); + await Task.Delay(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + await sut.StopAsync(ctSrc.Token); + + Mock.Get(sessionCleanupService) + .Verify(x => x.RemoveExpiredServerSideSessionsAsync(), Times.Between(3, 5, Range.Inclusive)); + } + + [Fact] + public async Task SessionCleanupHostedService_WhenFuzzEnabled_ShouldTriggerAtMost2Times() + { + options.ServerSideSessions.FuzzExpiredSessionsFrequency = true; + + CancellationTokenSource ctSrc = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + + var sut = CreateSut(); + + await sut.StartAsync(ctSrc.Token); + await Task.Delay(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + await sut.StopAsync(ctSrc.Token); + + Mock.Get(sessionCleanupService) + .Verify(x => x.RemoveExpiredServerSideSessionsAsync(), Times.AtMost(2)); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/SessionCleanupServiceTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/SessionCleanupServiceTests.cs index b47522027..685b7d555 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/SessionCleanupServiceTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Services/Default/SessionCleanupServiceTests.cs @@ -16,6 +16,7 @@ using Open.IdentityServer.Extensions; using Open.IdentityServer.Models; using Open.IdentityServer.Services; +using Open.IdentityServer.Services.Default; using Open.IdentityServer.Stores; using Xunit; From e06331edf8e0ccd44844a802826b4cab5818a4da Mon Sep 17 00:00:00 2001 From: James Britton Date: Mon, 14 Sep 2026 10:30:05 +0100 Subject: [PATCH 36/59] pr: working on pr review comments --- src/EntityFramework/src/TokenCleanupHost.cs | 23 ++++++++----------- .../SessionCleanupHostedService.cs | 8 +++---- .../Services/Default/SessionCleanupService.cs | 20 ++++++++-------- 3 files changed, 23 insertions(+), 28 deletions(-) diff --git a/src/EntityFramework/src/TokenCleanupHost.cs b/src/EntityFramework/src/TokenCleanupHost.cs index 736511d5a..ab89203f9 100644 --- a/src/EntityFramework/src/TokenCleanupHost.cs +++ b/src/EntityFramework/src/TokenCleanupHost.cs @@ -1,7 +1,6 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. - using Microsoft.Extensions.Hosting; using System.Threading; using System.Threading.Tasks; @@ -51,7 +50,7 @@ public Task StartAsync(CancellationToken cancellationToken) _source = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - Task.Factory.StartNew(() => StartInternalAsync(_source.Token)); + Task.Factory.StartNew(() => StartInternalAsync(_source.Token), cancellationToken); } return Task.CompletedTask; @@ -81,7 +80,7 @@ private async Task StartInternalAsync(CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested) { - _logger.LogDebug("CancellationRequested. Exiting."); + _logger.LogDebug("CancellationRequested. Exiting"); break; } @@ -89,20 +88,20 @@ private async Task StartInternalAsync(CancellationToken cancellationToken) { await Task.Delay(CleanupInterval, cancellationToken); } - catch (TaskCanceledException) + catch (TaskCanceledException ex) { - _logger.LogDebug("TaskCanceledException. Exiting."); + _logger.LogDebug(ex, "TaskCanceledException. Exiting"); break; } catch (Exception ex) { - _logger.LogError("Task.Delay exception: {0}. Exiting.", ex.Message); + _logger.LogError(ex, "Task.Delay exception: {ExceptionMsg}. Exiting", ex.Message); break; } if (cancellationToken.IsCancellationRequested) { - _logger.LogDebug("CancellationRequested. Exiting."); + _logger.LogDebug("CancellationRequested. Exiting"); break; } @@ -114,15 +113,13 @@ async Task RemoveExpiredGrantsAsync() { try { - using (var serviceScope = _serviceProvider.GetRequiredService().CreateScope()) - { - var tokenCleanupService = serviceScope.ServiceProvider.GetRequiredService(); - await tokenCleanupService.RemoveExpiredGrantsAsync(); - } + using var serviceScope = _serviceProvider.GetRequiredService().CreateScope(); + var tokenCleanupService = serviceScope.ServiceProvider.GetRequiredService(); + await tokenCleanupService.RemoveExpiredGrantsAsync(); } catch (Exception ex) { - _logger.LogError("Exception removing expired grants: {exception}", ex.Message); + _logger.LogError(ex, "Exception removing expired grants: {ExceptionMsg}", ex.Message); } } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/HostedServices/SessionCleanupHostedService.cs b/src/Open.IdentityServer/src/HostedServices/SessionCleanupHostedService.cs index c7563fc1a..49c5f274a 100644 --- a/src/Open.IdentityServer/src/HostedServices/SessionCleanupHostedService.cs +++ b/src/Open.IdentityServer/src/HostedServices/SessionCleanupHostedService.cs @@ -76,14 +76,14 @@ private async Task StartInternalAsync(CancellationToken cancellationToken) { await Task.Delay(options.ServerSideSessions.RemoveExpiredSessionsFrequency, cancellationToken); } - catch (TaskCanceledException) + catch (TaskCanceledException ex) { - logger.LogDebug("TaskCanceledException. Exiting"); + logger.LogDebug(ex, "TaskCanceledException. Exiting"); break; } catch (Exception ex) { - logger.LogError("Task.Delay exception: {ExceptionMsg}. Exiting", ex.Message); + logger.LogError(ex, "Task.Delay exception: {ExceptionMsg}. Exiting", ex.Message); break; } @@ -114,7 +114,7 @@ private async Task RemoveExpiredSessions() } catch (Exception ex) { - logger.LogError("Exception removing expired sessions: {ExceptionMsg}", ex.Message); + logger.LogError(ex, "Exception removing expired sessions: {ExceptionMsg}", ex.Message); } } } \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Services/Default/SessionCleanupService.cs b/src/Open.IdentityServer/src/Services/Default/SessionCleanupService.cs index bcc430e94..1de0c8d80 100644 --- a/src/Open.IdentityServer/src/Services/Default/SessionCleanupService.cs +++ b/src/Open.IdentityServer/src/Services/Default/SessionCleanupService.cs @@ -43,7 +43,7 @@ public async Task RemoveExpiredServerSideSessionsAsync() } catch (Exception ex) { - logger.LogError("Exception removing expired sessions: {Exception}", ex.Message); + logger.LogError(ex, "Exception removing expired sessions: {Exception}", ex.Message); } } @@ -63,19 +63,17 @@ private async Task RemoveServerSideSessionsAsync() found = expiredSessions.Count; logger.LogInformation("Removed {ExpiredSessionsCount} expired server side sessions", found); + + if (found <= 0) continue; - if (found > 0) + foreach (var expiredSession in expiredSessions) { - foreach (var expiredSession in expiredSessions) + await userSessionEventsService.HandleUserSessionExpiry(new EndUserSessionEventContext() { - // TODO, finish implementing, should get auth ticket so clientIds can be populated - await userSessionEventsService.HandleUserSessionExpiry(new EndUserSessionEventContext() - { - SubjectId = expiredSession.Session.SubjectId, - SessionId = expiredSession.Session.SessionId, - ClientIds = expiredSession.AuthTicket?.Properties.GetClientList().ToArray() ?? [], - }); - } + SubjectId = expiredSession.Session.SubjectId, + SessionId = expiredSession.Session.SessionId, + ClientIds = expiredSession.AuthTicket?.Properties.GetClientList().ToArray() ?? [], + }); } } } From c4855b9a53a1347112bec3b76d5cd8025de97d4f Mon Sep 17 00:00:00 2001 From: James Britton Date: Fri, 18 Sep 2026 16:29:25 +0100 Subject: [PATCH 37/59] test: created shared utilities project for testing --- .../LocalTimeZoneInfoMocker.cs | 3 +-- .../Open.IdentityServer.Test.Utilities.csproj | 9 +++++++++ Open.IdentityServer.sln | 9 +++++++++ .../Open.IdentityServer.UnitTests.csproj | 1 + .../IdentityServerSigningCredentialStoreTests.cs | 2 +- 5 files changed, 21 insertions(+), 3 deletions(-) rename {src/Open.IdentityServer/test/Open.IdentityServer.UnitTests => Open.IdentityServer.Test.Utilities}/LocalTimeZoneInfoMocker.cs (91%) create mode 100644 Open.IdentityServer.Test.Utilities/Open.IdentityServer.Test.Utilities.csproj diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/LocalTimeZoneInfoMocker.cs b/Open.IdentityServer.Test.Utilities/LocalTimeZoneInfoMocker.cs similarity index 91% rename from src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/LocalTimeZoneInfoMocker.cs rename to Open.IdentityServer.Test.Utilities/LocalTimeZoneInfoMocker.cs index b881e3cb9..e2865a037 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/LocalTimeZoneInfoMocker.cs +++ b/Open.IdentityServer.Test.Utilities/LocalTimeZoneInfoMocker.cs @@ -1,7 +1,6 @@ -using System; using System.Reflection; -namespace Open.IdentityServer.UnitTests; +namespace Open.IdentityServer.Test.Utilities; public class LocalTimeZoneInfoMocker: IDisposable { diff --git a/Open.IdentityServer.Test.Utilities/Open.IdentityServer.Test.Utilities.csproj b/Open.IdentityServer.Test.Utilities/Open.IdentityServer.Test.Utilities.csproj new file mode 100644 index 000000000..237d66167 --- /dev/null +++ b/Open.IdentityServer.Test.Utilities/Open.IdentityServer.Test.Utilities.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/Open.IdentityServer.sln b/Open.IdentityServer.sln index 701242153..00c3524ee 100644 --- a/Open.IdentityServer.sln +++ b/Open.IdentityServer.sln @@ -72,6 +72,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{7A351D3D EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Open.IdentityServer.Storage.UnitTests", "src\Storage\tests\Open.IdentityServer.Storage.UnitTests\Open.IdentityServer.Storage.UnitTests.csproj", "{FF1944C8-2516-4197-9B63-8BB3581882B3}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{36985CF3-7E79-4BF4-91FD-3E9195A0FCEA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Open.IdentityServer.Test.Utilities", "Open.IdentityServer.Test.Utilities\Open.IdentityServer.Test.Utilities.csproj", "{F0AD47D4-F27B-4265-B1B9-B2346824CBE4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -109,6 +113,7 @@ Global {C459AC33-FD11-402E-8058-F76DD940190D} = {78510D3C-6BE1-4017-8AD4-CC905D58D788} {7A351D3D-4AC7-4662-8BF3-8F42D6097F87} = {7C740022-8283-4021-8E72-3F2847949309} {FF1944C8-2516-4197-9B63-8BB3581882B3} = {7A351D3D-4AC7-4662-8BF3-8F42D6097F87} + {F0AD47D4-F27B-4265-B1B9-B2346824CBE4} = {36985CF3-7E79-4BF4-91FD-3E9195A0FCEA} EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {A2D626B8-1532-4E35-B9F2-4246C838D192}.Debug|Any CPU.ActiveCfg = Debug|Any CPU @@ -183,5 +188,9 @@ Global {FF1944C8-2516-4197-9B63-8BB3581882B3}.Debug|Any CPU.Build.0 = Debug|Any CPU {FF1944C8-2516-4197-9B63-8BB3581882B3}.Release|Any CPU.ActiveCfg = Release|Any CPU {FF1944C8-2516-4197-9B63-8BB3581882B3}.Release|Any CPU.Build.0 = Release|Any CPU + {F0AD47D4-F27B-4265-B1B9-B2346824CBE4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F0AD47D4-F27B-4265-B1B9-B2346824CBE4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F0AD47D4-F27B-4265-B1B9-B2346824CBE4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F0AD47D4-F27B-4265-B1B9-B2346824CBE4}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal 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 172649d7a..89183e118 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 @@ -36,6 +36,7 @@ + diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/IdentityServerSigningCredentialStoreTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/IdentityServerSigningCredentialStoreTests.cs index b43068f67..0b4f392b5 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/IdentityServerSigningCredentialStoreTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/IdentityServerSigningCredentialStoreTests.cs @@ -17,7 +17,7 @@ using Open.IdentityServer.Models; using Open.IdentityServer.Services; using Open.IdentityServer.Stores; -using Open.IdentityServer.UnitTests; +using Open.IdentityServer.Test.Utilities; using Xunit; namespace Open.IdentityServer.UnitTests.Stores.Compatibility; From 963ee0ace9f3e41eea16b369ee1b6921d2c85408 Mon Sep 17 00:00:00 2001 From: James Britton Date: Fri, 18 Sep 2026 16:31:36 +0100 Subject: [PATCH 38/59] pr: correcting issues commented on in PR --- .../IdentityServerServerSideSessionStore.cs | 3 +- ...er.EntityFramework.IntegrationTests.csproj | 2 + ...entityServerServerSideSessionStoreTests.cs | 89 +++++++++++++------ .../Services/Default/SessionCleanupService.cs | 14 ++- 4 files changed, 74 insertions(+), 34 deletions(-) diff --git a/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs b/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs index d8f4d7eda..52034fc0d 100644 --- a/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs +++ b/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs @@ -23,6 +23,7 @@ namespace Open.IdentityServer.EntityFramework.Stores; public class IdentityServerServerSideSessionStore( IPersistedGrantDbContext dbContext, ITelemetryService telemetry, + TimeProvider timeProvider, ILogger logger) : IIdentityServerServerSideSessionStore { /// @@ -145,7 +146,7 @@ public async Task> GetAndRemoveExp using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); var sessions = await dbContext.ServerSideSessions - .Where(x => x.Expires < DateTime.UtcNow) + .Where(x => x.Expires < timeProvider.GetUtcNow().UtcDateTime) .OrderBy(x => x.Expires) .Take(batchSize) .ToArrayAsync(); diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Open.IdentityServer.EntityFramework.IntegrationTests.csproj b/src/EntityFramework.Storage/test/IntegrationTests/Open.IdentityServer.EntityFramework.IntegrationTests.csproj index 06f928251..e28a43e9f 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/Open.IdentityServer.EntityFramework.IntegrationTests.csproj +++ b/src/EntityFramework.Storage/test/IntegrationTests/Open.IdentityServer.EntityFramework.IntegrationTests.csproj @@ -14,6 +14,7 @@ + @@ -26,6 +27,7 @@ + diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs index 25cd2151d..381739c47 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs @@ -5,12 +5,14 @@ using AwesomeAssertions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +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.Services; +using Open.IdentityServer.Test.Utilities; using Xunit; using SessionModel = Open.IdentityServer.Models.IdentityServerServerSideSessions; @@ -19,7 +21,10 @@ namespace Open.IdentityServer.EntityFramework.IntegrationTests.Stores.Compatibil public class IdentityServerServerSideSessionStoreTests: IntegrationTest { private readonly ITelemetryService telemetry = Mock.Of(); + private readonly FakeTimeProvider timeProvider = new(); private readonly MockLogger fakeLogger = new(); + + private static readonly DateTime FakeNow = new(2025, 02, 27, 12, 00, 00, DateTimeKind.Utc); public IdentityServerServerSideSessionStoreTests(DatabaseProviderFixture fixture) : base(fixture) { @@ -28,10 +33,12 @@ public IdentityServerServerSideSessionStoreTests(DatabaseProviderFixture - new(dbContext, telemetry, fakeLogger); + new(dbContext, telemetry, timeProvider, fakeLogger); [Theory] [InlineData(null)] @@ -71,9 +78,9 @@ public async Task GetSession_WhenExist_ShouldReturnValue(DbContextOptions x.Key == expiredSession0.Key); actual.Should().Contain(x => x.Key == expiredSession1.Key); } + + //TODO: Finish implementing test + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task GetAndRemoveExpiredSessions_WhenUnspecifiedTimezoneInDbEntities_ShouldBeTreatedAsUtc(DbContextOptions options) + { + //Ensuring timezone info is the same across environments + using var mockedTimezone = new LocalTimeZoneInfoMocker(TimeZoneInfo.FindSystemTimeZoneById("China Standard Time")); + + DateTime nowUnspecified = new(2025, 02, 27, 12, 12, 11, DateTimeKind.Unspecified); + + var testExpired = nowUnspecified.AddDays(-1); + var testWithin8HoursToExpiry = nowUnspecified.AddHours(2); + + await using PersistedGrantDbContext context = await CreateCleanContext(options); + + IdentityServerServerSideSessions expiredSession0 = FakeSessionSession("123", "session1"); + IdentityServerServerSideSessions validSession0 = FakeSessionSession("234", "session4"); + + expiredSession0.Expires = testExpired; + validSession0.Expires = testWithin8HoursToExpiry; + + context.ServerSideSessions.Add(expiredSession0); + context.ServerSideSessions.Add(validSession0); + await context.SaveChangesAsync(); + + IdentityServerServerSideSessionStore sut = CreateSut(context); + + List actual = (await sut.GetAndRemoveExpiredSessions(2)).ToList(); + + actual.Should().HaveCount(1); + actual.Should().Contain(x => x.Key == expiredSession0.Key); + } [Theory, MemberData(nameof(TestDatabaseProviders))] public async Task PublicMethods_WhenCalled_ShouldTelemetryTrace(DbContextOptions options) @@ -466,9 +505,9 @@ private static SessionModel BuildSessionModel(string key, string subjectId, stri SubjectId = subjectId, SessionId = sessionId, DisplayName = displayName, - Created = DateTime.UtcNow.AddMinutes(-10), - Renewed = DateTime.UtcNow.AddMinutes(-5), - Expires = DateTime.UtcNow.AddHours(1), + Created = FakeNow.AddMinutes(-10), + Renewed = FakeNow.AddMinutes(-5), + Expires = FakeNow.AddHours(1), Data = "{\"payload\":\"value\"}" }; } @@ -482,17 +521,17 @@ private static IdentityServerServerSideSessions FakeSessionSession(string subjec SubjectId = subject, SessionId = sessionId, DisplayName = "user" + subject, - Created = DateTime.UtcNow.AddDays(-3), - Renewed = DateTime.UtcNow.AddDays(-3), - Expires = DateTime.UtcNow.AddDays(2), + Created = FakeNow.AddDays(-3), + Renewed = FakeNow.AddDays(-3), + Expires = FakeNow.AddDays(2), Data = "{!}" }; if (expired) { - session.Created = DateTime.UtcNow.AddDays(-5); - session.Renewed = DateTime.UtcNow.AddDays(-4); - session.Expires = DateTime.UtcNow.AddDays(-3); + session.Created = FakeNow.AddDays(-5); + session.Renewed = FakeNow.AddDays(-4); + session.Expires = FakeNow.AddDays(-3); } return session; diff --git a/src/Open.IdentityServer/src/Services/Default/SessionCleanupService.cs b/src/Open.IdentityServer/src/Services/Default/SessionCleanupService.cs index 1de0c8d80..ad655cbaa 100644 --- a/src/Open.IdentityServer/src/Services/Default/SessionCleanupService.cs +++ b/src/Open.IdentityServer/src/Services/Default/SessionCleanupService.cs @@ -53,28 +53,26 @@ public async Task RemoveExpiredServerSideSessionsAsync() /// A that completes once all batches of expired sessions have been deleted. private async Task RemoveServerSideSessionsAsync() { - var found = Int32.MaxValue; + int found; - while (found >= options.ServerSideSessions.RemoveExpiredSessionsBatchSize) + do { var expiredSessions = (await serverSideSessionStore - .GetAndRemoveExpiredSessions(options.ServerSideSessions.RemoveExpiredSessionsBatchSize)) + .GetAndRemoveExpiredSessions(options.ServerSideSessions.RemoveExpiredSessionsBatchSize)) .ToList(); - + found = expiredSessions.Count; logger.LogInformation("Removed {ExpiredSessionsCount} expired server side sessions", found); - if (found <= 0) continue; - foreach (var expiredSession in expiredSessions) { await userSessionEventsService.HandleUserSessionExpiry(new EndUserSessionEventContext() { SubjectId = expiredSession.Session.SubjectId, SessionId = expiredSession.Session.SessionId, - ClientIds = expiredSession.AuthTicket?.Properties.GetClientList().ToArray() ?? [], + ClientIds = expiredSession.AuthTicket?.Properties.GetClientList().ToArray() ?? [], }); } - } + } while (found >= options.ServerSideSessions.RemoveExpiredSessionsBatchSize); } } \ No newline at end of file From 0fd2edf682f1a45503fe73e289fea4d040f8653c Mon Sep 17 00:00:00 2001 From: James Britton Date: Mon, 21 Sep 2026 17:46:02 +0100 Subject: [PATCH 39/59] fix: broken utilities project build --- Open.IdentityServer.sln | 2 +- build.ps1 | 4 ++++ build.sh | 4 ++++ ...pen.IdentityServer.EntityFramework.IntegrationTests.csproj | 2 +- .../LocalTimeZoneInfoMocker.cs | 0 .../Open.IdentityServer.Test.Utilities.csproj | 0 src/Open.IdentityServer.Test.Utilities/build.cmd | 2 ++ src/Open.IdentityServer.Test.Utilities/build.ps1 | 2 ++ src/Open.IdentityServer.Test.Utilities/build.sh | 3 +++ .../Open.IdentityServer.UnitTests.csproj | 2 +- .../Compatibility/IdentityServerValidationKeysStoreTests.cs | 2 +- 11 files changed, 19 insertions(+), 4 deletions(-) rename {Open.IdentityServer.Test.Utilities => src/Open.IdentityServer.Test.Utilities}/LocalTimeZoneInfoMocker.cs (100%) rename {Open.IdentityServer.Test.Utilities => src/Open.IdentityServer.Test.Utilities}/Open.IdentityServer.Test.Utilities.csproj (100%) create mode 100644 src/Open.IdentityServer.Test.Utilities/build.cmd create mode 100644 src/Open.IdentityServer.Test.Utilities/build.ps1 create mode 100644 src/Open.IdentityServer.Test.Utilities/build.sh diff --git a/Open.IdentityServer.sln b/Open.IdentityServer.sln index 00c3524ee..30f776ab3 100644 --- a/Open.IdentityServer.sln +++ b/Open.IdentityServer.sln @@ -74,7 +74,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Open.IdentityServer.Storage EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{36985CF3-7E79-4BF4-91FD-3E9195A0FCEA}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Open.IdentityServer.Test.Utilities", "Open.IdentityServer.Test.Utilities\Open.IdentityServer.Test.Utilities.csproj", "{F0AD47D4-F27B-4265-B1B9-B2346824CBE4}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Open.IdentityServer.Test.Utilities", "src\Open.IdentityServer.Test.Utilities\Open.IdentityServer.Test.Utilities.csproj", "{F0AD47D4-F27B-4265-B1B9-B2346824CBE4}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/build.ps1 b/build.ps1 index 899313559..94fdec65e 100644 --- a/build.ps1 +++ b/build.ps1 @@ -4,6 +4,10 @@ New-Item -ItemType Directory -Force -Path ./nuget dotnet tool restore +pushd ./src/Open.IdentityServer.Test.Utilities +Invoke-Expression "dotnet build" +popd + pushd ./src/Storage Invoke-Expression "./build.ps1 $args" popd diff --git a/build.sh b/build.sh index a566c62a8..4712e94a7 100755 --- a/build.sh +++ b/build.sh @@ -6,6 +6,10 @@ mkdir nuget dotnet tool restore +pushd ./src/Open.IdentityServer.Test.Utilities +dotnet build +popd + pushd ./src/Storage ./build.sh "$@" popd diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Open.IdentityServer.EntityFramework.IntegrationTests.csproj b/src/EntityFramework.Storage/test/IntegrationTests/Open.IdentityServer.EntityFramework.IntegrationTests.csproj index e28a43e9f..b94e80d4f 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/Open.IdentityServer.EntityFramework.IntegrationTests.csproj +++ b/src/EntityFramework.Storage/test/IntegrationTests/Open.IdentityServer.EntityFramework.IntegrationTests.csproj @@ -14,7 +14,7 @@ - + diff --git a/Open.IdentityServer.Test.Utilities/LocalTimeZoneInfoMocker.cs b/src/Open.IdentityServer.Test.Utilities/LocalTimeZoneInfoMocker.cs similarity index 100% rename from Open.IdentityServer.Test.Utilities/LocalTimeZoneInfoMocker.cs rename to src/Open.IdentityServer.Test.Utilities/LocalTimeZoneInfoMocker.cs diff --git a/Open.IdentityServer.Test.Utilities/Open.IdentityServer.Test.Utilities.csproj b/src/Open.IdentityServer.Test.Utilities/Open.IdentityServer.Test.Utilities.csproj similarity index 100% rename from Open.IdentityServer.Test.Utilities/Open.IdentityServer.Test.Utilities.csproj rename to src/Open.IdentityServer.Test.Utilities/Open.IdentityServer.Test.Utilities.csproj diff --git a/src/Open.IdentityServer.Test.Utilities/build.cmd b/src/Open.IdentityServer.Test.Utilities/build.cmd new file mode 100644 index 000000000..e4cf78f4a --- /dev/null +++ b/src/Open.IdentityServer.Test.Utilities/build.cmd @@ -0,0 +1,2 @@ +@echo off +dotnet run --project build -- %* \ No newline at end of file diff --git a/src/Open.IdentityServer.Test.Utilities/build.ps1 b/src/Open.IdentityServer.Test.Utilities/build.ps1 new file mode 100644 index 000000000..8c950bdae --- /dev/null +++ b/src/Open.IdentityServer.Test.Utilities/build.ps1 @@ -0,0 +1,2 @@ +$ErrorActionPreference = "Stop"; +dotnet run --project build -- $args \ No newline at end of file diff --git a/src/Open.IdentityServer.Test.Utilities/build.sh b/src/Open.IdentityServer.Test.Utilities/build.sh new file mode 100644 index 000000000..bb8ef5b20 --- /dev/null +++ b/src/Open.IdentityServer.Test.Utilities/build.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +dotnet run --project build -- "$@" \ No newline at end of file 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 89183e118..ba8a01d45 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 @@ -36,7 +36,7 @@ - + diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/IdentityServerValidationKeysStoreTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/IdentityServerValidationKeysStoreTests.cs index af18f1543..cd29d3468 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/IdentityServerValidationKeysStoreTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Compatibility/IdentityServerValidationKeysStoreTests.cs @@ -15,7 +15,7 @@ using Open.IdentityServer.Models; using Open.IdentityServer.Services; using Open.IdentityServer.Stores; -using Open.IdentityServer.UnitTests; +using Open.IdentityServer.Test.Utilities; using Xunit; namespace Open.IdentityServer.UnitTests.Stores.Compatibility; From 82a3d3c4c464ddae81a75f0e20ab112f6b9a183d Mon Sep 17 00:00:00 2001 From: James Britton Date: Tue, 22 Sep 2026 10:28:33 +0100 Subject: [PATCH 40/59] version: bumped version number to 3.0 --- Directory.Build.props | 4 ++-- src/Directory.Build.targets | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index af77b9d53..b61c22c56 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 2.0.0 - 2.0 + 3.0.0 + 3.0 RockSolidKnowledge CS1591;CS1570;CS1571;CS1572;CS1573;CS1574;CS1580;CS1581;CS1584;CS1587;CS1591;CS1658;CS1712;CS1734 diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 2e58164dd..933fcc1c2 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -5,7 +5,7 @@ 10.0.5 10.0.10 - 2.1.0-* + 3.0.0-* From f436ff2421bcff76dca4a1da7ba21ac9b52647de Mon Sep 17 00:00:00 2001 From: James Britton Date: Fri, 11 Sep 2026 16:27:14 +0100 Subject: [PATCH 41/59] 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 42/59] 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 43/59] 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 44/59] 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 45/59] 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 46/59] 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 47/59] 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 48/59] 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, }); } From 4c0f3a408024d1b820903602087f7556f9f3baf0 Mon Sep 17 00:00:00 2001 From: James Britton Date: Wed, 9 Sep 2026 13:53:17 +0100 Subject: [PATCH 49/59] docs: adding docs for implemented so far features of server side sessions --- docs/index.rst | 1 + docs/reference/options.rst | 21 +++++++ docs/topics/server_side_sessions.rst | 83 ++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 docs/topics/server_side_sessions.rst diff --git a/docs/index.rst b/docs/index.rst index 273c959de..f6587511f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -80,6 +80,7 @@ Open.IdentityServer enables the following features in your applications: topics/compatibility topics/resources topics/clients + topics/server_side_sessions topics/signin topics/signin_external_providers topics/windows diff --git a/docs/reference/options.rst b/docs/reference/options.rst index 040115b8d..5b537d0e1 100644 --- a/docs/reference/options.rst +++ b/docs/reference/options.rst @@ -155,3 +155,24 @@ Mutual TLS Specifies whether a cnf claim gets emitted for access tokens if a client certificate was present. Normally the cnf claims only gets emitted if the client used the client certificate for authentication, setting this to true, will set the claim regardless of the authentication method. (defaults to false). + +Server-Side Sessions +^^^^^^^^^^^^^^^^^^^^ + +* ``ExpiredSessionsTriggerBackchannelLogout`` + Specifies if session expiry should trigger back channel logout, this will override any other settings that may + cause back channel logout such as AuthenticationOptions.CoordinateClientLifetimesWithUserSession or + Client.CoordinateLifetimeWithUserSession. + +* ``RemoveExpiredSessions`` + Specifies if expired sessions should be cleaned up automatically by Open.IdentityServer. The default value is true. + +* ``RemoveExpiredSessionsFrequency`` + Specifies the frequency with which expired sessions are looked for and removed. The default value is a TimeSpan of 10 minutes. + +* ``FuzzExpiredSessionsFrequency`` + 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. The default value is true. + +* ``RemoveExpiredSessionsBatchSize`` + Specifies how many expired sessions should be removed in a single pass. The default value is 100. diff --git a/docs/topics/server_side_sessions.rst b/docs/topics/server_side_sessions.rst new file mode 100644 index 000000000..ae222a94a --- /dev/null +++ b/docs/topics/server_side_sessions.rst @@ -0,0 +1,83 @@ +.. _refServerSideSessions: + +Server-Side Sessions +==================== + +Overview +-------- +When users authenticate with Open.IdentityServer, a session is created to track the logged in user. By default this will be done +storing this state in cookies in the user browser. This approach of storing session state in a cookie can work well for many scenarios +but does have some drawbacks. + +* **No Tracking Active Sessions** - There is no way to track active sessions, and how many users are currently logged in. +* **No Immidiate Revokation** - There will be no process of immidiate session revokation on the serevr ad a session cookie will be valid till it expires, or they log out. +* **No Sign-Out Coordination** - Coordinating sign-outs from Open.IdentityServer with connected clients is less relable without server tracking of active sessions. + +Open.IdentityServer Server-Side Sessions solves these issues by storing the contents of this cookie in a server side data store. This enables +in Open.IdentityServer the ability to: + +* Provide APIs for manging and querying active user sessions +* Support for explicit session revokation regardless of the cookie state in the browser +* Storing session data server side, so browser cookie only contains an ID for the session nothing more + +Getting Started +^^^^^^^^^^^^^^^ + +1. **Database schema** + + Ensure your database is in the correct state. If you are coming from Duende + IdentityServer, there is nothing further to do - the schema is already compatible. + If you are migrating from IdentityServer4 and have not yet updated your schema to + match the Open.IdentityServer schema, you will need to do this first. See + :ref:`migration from IdentityServer4 ` for details. + +2. **Enable server-side sessions** + + Call ``.AddServerSideSessions()`` when configuring Open.IdentityServer: + + .. code-block:: csharp + + builder.Services.AddIdentityServer() + .AddServerSideSessions(); + +3. **Configure a session store** + + ``AddServerSideSessions`` requires an implementation of ``IServerSideSessionStore`` + to persist session data. If you are using Entity Framework Core, this is provided + automatically when you configure the operational store: + + .. code-block:: csharp + + builder.Services.AddIdentityServer() + .AddServerSideSessions() + .AddOperationalStore(options => + { + options.ConfigureDbContext = b => + b.UseSqlServer("ConnectionString"); + }); + + If you are not using the built-in EF Core store, you will need to provide your own + ``IServerSideSessionStore`` implementation. + +5. **(Optional) Configure additional session options** + + You can customize behavior via ``ServerSideSessionOptions``, such as how often + sessions are checked for expiration in the background, or coordinating this with + your sign-in cookie expiration: + + .. code-block:: csharp + + builder.Services.AddIdentityServer(options => + { + // Other options... + options.ServerSideSessions.ExpiredSessionsTriggerBackchannelLogout = true; + options.ServerSideSessions.RemoveExpiredSessions = true; + options.ServerSideSessions.RemoveExpiredSessionsFrequency = TimeSpan.FromSeconds(10); + options.ServerSideSessions.FuzzExpiredSessionsFrequency = true; + options.ServerSideSessions.RemoveExpiredSessionsBatchSize = 100; + }); + +Managemet Interface +^^^^^^^^^^^^^^^^^^^ + +TODO: not yet implemented \ No newline at end of file From d5754445e40b1e717418c73e0dd351bfc0859165 Mon Sep 17 00:00:00 2001 From: James Britton Date: Mon, 14 Sep 2026 11:41:44 +0100 Subject: [PATCH 50/59] pr: updated after review --- docs/migrating/from_duende.rst | 2 -- docs/reference/options.rst | 4 ++-- docs/topics/server_side_sessions.rst | 30 +++++++++------------------- 3 files changed, 11 insertions(+), 25 deletions(-) diff --git a/docs/migrating/from_duende.rst b/docs/migrating/from_duende.rst index 6a9f7b64d..90e4a7d75 100644 --- a/docs/migrating/from_duende.rst +++ b/docs/migrating/from_duende.rst @@ -71,8 +71,6 @@ Migration Steps - Remove all references. Not yet supported in Open.IdentityServer. * - Automatic Key Management - Remove all references. You will need to configure signing keys manually, or :ref:`configure read-only key store `. - * - Server Side Sessions - - Remove all references. Not yet supported in Open.IdentityServer. * - Pushed Authorisation Requests (PAR) - Remove all references. Not yet supported in Open.IdentityServer. * - CIBA (Client Initiated Backchannel Authentication) diff --git a/docs/reference/options.rst b/docs/reference/options.rst index 5b537d0e1..a3bdbe8cf 100644 --- a/docs/reference/options.rst +++ b/docs/reference/options.rst @@ -171,8 +171,8 @@ Server-Side Sessions Specifies the frequency with which expired sessions are looked for and removed. The default value is a TimeSpan of 10 minutes. * ``FuzzExpiredSessionsFrequency`` - 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. The default value is true. + Specifies if the start time of the hosted service should be randomised. This avoids the scenario where multiple running instances + of identityserver run cleanup jobs simultaneously. The default value is true. * ``RemoveExpiredSessionsBatchSize`` Specifies how many expired sessions should be removed in a single pass. The default value is 100. diff --git a/docs/topics/server_side_sessions.rst b/docs/topics/server_side_sessions.rst index ae222a94a..c6838c833 100644 --- a/docs/topics/server_side_sessions.rst +++ b/docs/topics/server_side_sessions.rst @@ -5,19 +5,16 @@ Server-Side Sessions Overview -------- -When users authenticate with Open.IdentityServer, a session is created to track the logged in user. By default this will be done -storing this state in cookies in the user browser. This approach of storing session state in a cookie can work well for many scenarios -but does have some drawbacks. +When users authenticate with Open.IdentityServer, a session is created to track the logged in user. By default this will be done by storing this state in cookies in the user browser. This approach of storing session state in a cookie can work well for many scenarios but does have some drawbacks. * **No Tracking Active Sessions** - There is no way to track active sessions, and how many users are currently logged in. -* **No Immidiate Revokation** - There will be no process of immidiate session revokation on the serevr ad a session cookie will be valid till it expires, or they log out. +* **No Immediate Revocation** - There will be no process of Immediate session revocation on the server side-session cookie will be valid till it expires, or they log out. * **No Sign-Out Coordination** - Coordinating sign-outs from Open.IdentityServer with connected clients is less relable without server tracking of active sessions. -Open.IdentityServer Server-Side Sessions solves these issues by storing the contents of this cookie in a server side data store. This enables -in Open.IdentityServer the ability to: +Open.IdentityServer Server-Side Sessions solves these issues by storing the contents of this cookie in a server side data store. This gives Open.IdentityServer the ability to: -* Provide APIs for manging and querying active user sessions -* Support for explicit session revokation regardless of the cookie state in the browser +* Provide APIs for managing and querying active user sessions +* Support for explicit session revocation regardless of the cookie state in the browser * Storing session data server side, so browser cookie only contains an ID for the session nothing more Getting Started @@ -25,11 +22,7 @@ Getting Started 1. **Database schema** - Ensure your database is in the correct state. If you are coming from Duende - IdentityServer, there is nothing further to do - the schema is already compatible. - If you are migrating from IdentityServer4 and have not yet updated your schema to - match the Open.IdentityServer schema, you will need to do this first. See - :ref:`migration from IdentityServer4 ` for details. + Ensure your database is in the correct state. If you are coming from Duende IdentityServer, there is nothing further to do - the schema is already compatible. If you are migrating from IdentityServer4 and have not yet updated your schema to match the Open.IdentityServer schema, you will need to do this first. See :ref:`migration from IdentityServer4 ` for details. 2. **Enable server-side sessions** @@ -42,9 +35,7 @@ Getting Started 3. **Configure a session store** - ``AddServerSideSessions`` requires an implementation of ``IServerSideSessionStore`` - to persist session data. If you are using Entity Framework Core, this is provided - automatically when you configure the operational store: + ``AddServerSideSessions`` requires an implementation of ``IServerSideSessionStore`` to persist session data. If you are using Entity Framework Core, this is provided automatically when you configure the operational store: .. code-block:: csharp @@ -56,14 +47,11 @@ Getting Started b.UseSqlServer("ConnectionString"); }); - If you are not using the built-in EF Core store, you will need to provide your own - ``IServerSideSessionStore`` implementation. + If you are not using the built-in EF Core store, you will need to provide your own ``IServerSideSessionStore`` implementation. 5. **(Optional) Configure additional session options** - You can customize behavior via ``ServerSideSessionOptions``, such as how often - sessions are checked for expiration in the background, or coordinating this with - your sign-in cookie expiration: + You can customize behavior via ``ServerSideSessionOptions``, such as how often sessions are checked for expiration in the background, or coordinating this with your sign-in cookie expiration: .. code-block:: csharp From 41c5478e157dd17fe048b58bea53db4dca14ee17 Mon Sep 17 00:00:00 2001 From: Jo Stevens Date: Tue, 1 Sep 2026 16:26:36 +0100 Subject: [PATCH 51/59] test: integration test for serverside sessions --- .../Login/LoginServerSideSessionTests.cs | 144 +++++++++++++++++- ...pen.IdentityServer.IntegrationTests.csproj | 2 + 2 files changed, 141 insertions(+), 5 deletions(-) diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs index ee1bfac15..b8107ef46 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs @@ -9,7 +9,12 @@ using System.Threading.Tasks; using AwesomeAssertions; using IdentityServer.IntegrationTests.Common; +using IdentityServer.IntegrationTests.Utility; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Extensions.DependencyInjection; +using Moq; +using Open.IdentityServer.Extensions; using Open.IdentityServer.Models; using Open.IdentityServer.Stores; using Open.IdentityServer.Test; @@ -22,6 +27,7 @@ public class LoginServerSideSessionTests private const string Category = "LoginServerSideSessionTests"; private IdentityServerPipeline _mockPipeline = new IdentityServerPipeline(); + private ITicketStore ticketStore = null; private IIdentityServerServerSideSessionStore? sessionStore = null; public LoginServerSideSessionTests() @@ -32,20 +38,31 @@ public LoginServerSideSessionTests() new Client { ClientId = "client1", - AllowedGrantTypes = GrantTypes.Implicit, + AllowedGrantTypes = GrantTypes.Code, RequireConsent = false, - AllowedScopes = new List { "openid", "profile" }, + AllowedScopes = new List { "openid", "profile", "api1" }, RedirectUris = new List { "https://client1/callback" }, - AllowAccessTokensViaBrowser = true + AllowAccessTokensViaBrowser = true, + AllowOfflineAccess = true, + RequirePkce = false, + RequireClientSecret = false }, new Client { ClientId = "client2", AllowedGrantTypes = GrantTypes.Implicit, - RequireConsent = true, + RequireConsent = false, AllowedScopes = new List { "openid", "profile", "api1", "api2" }, RedirectUris = new List { "https://client2/callback" }, AllowAccessTokensViaBrowser = true + }, + new Client() + { + ClientId = "client3", + AllowedGrantTypes = GrantTypes.ClientCredentials, + AllowedScopes = new List { "api1", "api2" }, + RedirectUris = new List { "https://client3/callback" }, + AllowOfflineAccess = true } ]); @@ -97,6 +114,7 @@ public LoginServerSideSessionTests() _mockPipeline.OnPreConfigure += app => { + ticketStore = app.ApplicationServices.GetRequiredService(); sessionStore = app.ApplicationServices.GetRequiredService(); }; @@ -124,7 +142,7 @@ public async Task Login_ShouldCreateSessionInServerStore() [Fact] [Trait("Category", Category)] - public async Task Login_WhenUserChangfes_ShouldUpdateSessionInServerStore() + public async Task Login_WhenUserChanges_ShouldUpdateSessionInServerStore() { sessionStore.Should().NotBeNull(); @@ -149,4 +167,120 @@ public async Task Login_WhenUserChangfes_ShouldUpdateSessionInServerStore() updatedSession.SessionId.Should().Be(newSessionCookie.Value); updatedSession.SubjectId.Should().Be("alice"); } + + [Fact] + [Trait("Category", Category)] + public async Task login_when_multiple_clients_should_update_session_in_server_store() + { + sessionStore.Should().NotBeNull(); + + await _mockPipeline.LoginAsync("bob"); + + AuthenticationTicket? ticket = null; + + Cookie originalSessionCookie = _mockPipeline.GetSessionCookie(); + + var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); + authKey.Should().NotBeNull(); + + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + + var client1Authorization = + await _mockPipeline.RequestAuthorizationEndpointAsync( + clientId: "client1", + responseType: "code", + scope: "openid profile api1 offline_access", + redirectUri: "https://client1/callback", + state: "state", + nonce: "nonce"); + + client1Authorization.IsError.Should().BeFalse(); + client1Authorization.IdentityToken.Should().BeNull(); + client1Authorization.State.Should().Be("state"); + + var tokenClient1 = new TokenClient( + _mockPipeline.BackChannelClient!, + new TokenClientOptions + { + Address = IdentityServerPipeline.TokenEndpoint, + ClientId = "client1" + }); + + var tokenResponse = await tokenClient1.RequestAuthorizationCodeTokenAsync( + code: client1Authorization.Code!, + redirectUri: "https://client1/callback", + cancellationToken: TestContext.Current.CancellationToken); + + tokenResponse.IsError.Should().BeFalse(); + tokenResponse.AccessToken.Should().NotBeNull(); + tokenResponse.IdentityToken.Should().NotBeNull(); + tokenResponse.RefreshToken.Should().NotBeNull(); + + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + + var initialIssuedUtc = ticket.Properties.IssuedUtc; + var initialExpiresUtc = ticket.Properties.ExpiresUtc; + + var clientList = ticket.Properties.GetClientList(); + clientList.Should().Contain("client1"); + + var client2Authorization = + await _mockPipeline.RequestAuthorizationEndpointAsync( + clientId: "client2", + responseType: "id_token", + scope: "openid profile", + redirectUri: "https://client2/callback", + state: "state2", + nonce: "nonce2"); + + client2Authorization.IsError.Should().BeFalse(); + client2Authorization.IdentityToken.Should().NotBeNull(); + client2Authorization.State.Should().Be("state2"); + + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + + clientList = ticket.Properties.GetClientList(); + clientList.Should().Contain("client1"); + clientList.Should().Contain("client2"); + + ticket.Properties.IssuedUtc.Should().Be(initialIssuedUtc!.Value); + ticket.Properties.ExpiresUtc.Should().BeAfter(initialExpiresUtc!.Value); + + var tokenClient3 = new TokenClient( + _mockPipeline.BackChannelClient!, + new TokenClientOptions + { + Address = IdentityServerPipeline.TokenEndpoint, + ClientId = "client3", + ClientSecret = "secret" + }); + + var accessTokenResponse = + await tokenClient3.RequestClientCredentialsTokenAsync( + scope: "api1 api2", + cancellationToken: TestContext.Current.CancellationToken); + + accessTokenResponse.IsError.Should().BeFalse(); + accessTokenResponse.AccessToken.Should().NotBeNull(); + accessTokenResponse.RefreshToken.Should().BeNull(); + + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + + clientList = ticket.Properties.GetClientList(); + clientList.Should().Contain("client1"); + clientList.Should().Contain("client2"); + clientList.Should().NotContain("client3"); + + ticket.Properties.IssuedUtc.Should().BeAfter(initialIssuedUtc!.Value); + + + } } \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Open.IdentityServer.IntegrationTests.csproj b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Open.IdentityServer.IntegrationTests.csproj index f280fc5d8..eb39b44fc 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Open.IdentityServer.IntegrationTests.csproj +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Open.IdentityServer.IntegrationTests.csproj @@ -24,6 +24,8 @@ + + From ca4006925e7172dcbb8b55fc3ce1b5b40120ba36 Mon Sep 17 00:00:00 2001 From: Jo Stevens Date: Thu, 24 Sep 2026 16:56:44 +0100 Subject: [PATCH 52/59] test: split sever side session test --- .../ConfigureInternalCookieOptions.cs | 3 +- .../Common/IdentityServerPipeline.cs | 13 + .../Login/LoginServerSideSessionTests.cs | 474 +++++++++++++++--- 3 files changed, 433 insertions(+), 57 deletions(-) diff --git a/src/Open.IdentityServer/src/Configuration/DependencyInjection/ConfigureInternalCookieOptions.cs b/src/Open.IdentityServer/src/Configuration/DependencyInjection/ConfigureInternalCookieOptions.cs index 86bd053f8..8d229501f 100644 --- a/src/Open.IdentityServer/src/Configuration/DependencyInjection/ConfigureInternalCookieOptions.cs +++ b/src/Open.IdentityServer/src/Configuration/DependencyInjection/ConfigureInternalCookieOptions.cs @@ -35,8 +35,9 @@ public void Configure(string name, CookieAuthenticationOptions options) options.ReturnUrlParameter = idsrv.UserInteraction.LoginReturnUrlParameter; } + var originalOnCheckSlidingExpiration = options.Events.OnCheckSlidingExpiration; options.Events.OnCheckSlidingExpiration = context => CookieAuthenticationEvents - .ServerSessionOnCheckSlidingExpiration(context, options.Events.OnCheckSlidingExpiration); + .ServerSessionOnCheckSlidingExpiration(context, originalOnCheckSlidingExpiration); } if (name == IdentityServerConstants.ExternalCookieAuthenticationScheme) diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs index 9b93c2a43..344753445 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs @@ -59,6 +59,7 @@ public class IdentityServerPipeline public const string AuthCookieSessionIdClaimType = "Microsoft.AspNetCore.Authentication.Cookies-SessionId"; public IdentityServerOptions? Options { get; set; } + public List Clients { get; set; } = new List(); public List IdentityScopes { get; set; } = new List(); public List ApiResources { get; set; } = new List(); @@ -143,6 +144,11 @@ public void ConfigureServices(IServiceCollection services) { Options = options; + if (EnableServerSideSessions) + { + options.Authentication.CookieSlidingExpiration = true; + } + options.Events = new EventsOptions { RaiseErrorEvents = true, @@ -156,6 +162,7 @@ public void ConfigureServices(IServiceCollection services) .AddInMemoryApiResources(ApiResources) .AddInMemoryApiScopes(ApiScopes) .AddTestUsers(Users) + .AddServerSideSessions() .AddDeveloperSigningCredential(persistKey: false); if (EnableServerSideSessions) @@ -345,6 +352,12 @@ public Cookie GetLoginCookie() var ticket = cookieOptions.TicketDataFormat.Unprotect(authCookie.Value); return ticket?.Principal?.FindFirst(AuthCookieSessionIdClaimType)?.Value; } + public ITicketStore GetTicketStore() + { + var optionsMonitor = Server!.Services.GetRequiredService>(); + var cookieOptions = optionsMonitor.Get(IdentityServerConstants.DefaultCookieAuthenticationScheme); + return cookieOptions.SessionStore!; + } public string CreateAuthorizeUrl( string clientId = null!, diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs index b8107ef46..fe66e0c89 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs @@ -3,9 +3,11 @@ #nullable enable +using System; using System.Collections.Generic; using System.Net; using System.Security.Claims; +using System.Text.Encodings.Web; using System.Threading.Tasks; using AwesomeAssertions; using IdentityServer.IntegrationTests.Common; @@ -13,7 +15,10 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; using Moq; +using Open.IdentityServer.Configuration; using Open.IdentityServer.Extensions; using Open.IdentityServer.Models; using Open.IdentityServer.Stores; @@ -27,37 +32,46 @@ public class LoginServerSideSessionTests private const string Category = "LoginServerSideSessionTests"; private IdentityServerPipeline _mockPipeline = new IdentityServerPipeline(); + private FakeTimeProvider fakeTimeProvider = new FakeTimeProvider(); private ITicketStore ticketStore = null; private IIdentityServerServerSideSessionStore? sessionStore = null; public LoginServerSideSessionTests() { - _mockPipeline.EnableServerSideSessions = true; - + fakeTimeProvider.SetUtcNow( + new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); + + _mockPipeline.Clients.AddRange([ new Client { + //TODO: Turn this into a ref code client ClientId = "client1", AllowedGrantTypes = GrantTypes.Code, + AccessTokenType = AccessTokenType.Reference, RequireConsent = false, AllowedScopes = new List { "openid", "profile", "api1" }, RedirectUris = new List { "https://client1/callback" }, - AllowAccessTokensViaBrowser = true, + FrontChannelLogoutUri = "https://client1/signout", AllowOfflineAccess = true, RequirePkce = false, - RequireClientSecret = false + RequireClientSecret = false, + CoordinateLifetimeWithUserSession = true }, new Client { ClientId = "client2", - AllowedGrantTypes = GrantTypes.Implicit, + AllowedGrantTypes = GrantTypes.Code, RequireConsent = false, AllowedScopes = new List { "openid", "profile", "api1", "api2" }, RedirectUris = new List { "https://client2/callback" }, - AllowAccessTokensViaBrowser = true + FrontChannelLogoutUri = "https://client2/signout", + RequirePkce = false, + RequireClientSecret = false, }, new Client() { + //TODO:// turn this in to a code client with non-ref tokens ClientId = "client3", AllowedGrantTypes = GrantTypes.ClientCredentials, AllowedScopes = new List { "api1", "api2" }, @@ -111,13 +125,30 @@ public LoginServerSideSessionTests() Name = "api2" } ]); - + _mockPipeline.OnPreConfigure += app => { - ticketStore = app.ApplicationServices.GetRequiredService(); sessionStore = app.ApplicationServices.GetRequiredService(); }; + _mockPipeline.OnPostConfigureServices += services => + { + services.Configure(options => + { + //Session Expirey is only update if more than half of the cookie lifetime has passed, + //so we set the cookie lifetime to 6 minutes, then update the TimeProvider by 5 minutes each step. + options.Authentication.CookieLifetime = TimeSpan.FromMinutes(6); + options.Authentication.CookieSlidingExpiration = true; + }); + + services.AddSingleton(fakeTimeProvider); + + services.PostConfigure( + IdentityServerConstants.DefaultCookieAuthenticationScheme, + options => { options.TimeProvider = fakeTimeProvider; }); + }; + + _mockPipeline.Initialize(); } @@ -125,15 +156,16 @@ public LoginServerSideSessionTests() [Trait("Category", Category)] public async Task Login_ShouldCreateSessionInServerStore() { - sessionStore.Should().NotBeNull(); + sessionStore.Should().NotBeNull(); + await _mockPipeline.LoginAsync("bob"); Cookie sessionCookie = _mockPipeline.GetSessionCookie(); var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); authKey.Should().NotBeNull(); - + var storedSession = await sessionStore.GetSession(authKey); storedSession.Should().NotBeNull(); storedSession.SessionId.Should().Be(sessionCookie.Value); @@ -145,48 +177,50 @@ public async Task Login_ShouldCreateSessionInServerStore() public async Task Login_WhenUserChanges_ShouldUpdateSessionInServerStore() { sessionStore.Should().NotBeNull(); - + await _mockPipeline.LoginAsync("bob"); Cookie originalSessionCookie = _mockPipeline.GetSessionCookie(); var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); authKey.Should().NotBeNull(); - + var originalSession = await sessionStore.GetSession(authKey); originalSession.Should().NotBeNull(); originalSession.SessionId.Should().Be(originalSessionCookie.Value); originalSession.SubjectId.Should().Be("bob"); - + await _mockPipeline.LoginAsync("alice"); Cookie newSessionCookie = _mockPipeline.GetSessionCookie(); - + var updatedSession = await sessionStore.GetSession(authKey); updatedSession.Should().NotBeNull(); updatedSession.SessionId.Should().Be(newSessionCookie.Value); updatedSession.SubjectId.Should().Be("alice"); } - + [Fact] [Trait("Category", Category)] public async Task login_when_multiple_clients_should_update_session_in_server_store() { + + //Setup + ticketStore = _mockPipeline.GetTicketStore(); sessionStore.Should().NotBeNull(); - + + // Initial login to create a session await _mockPipeline.LoginAsync("bob"); + // Verify that the session has been created in the store AuthenticationTicket? ticket = null; - - Cookie originalSessionCookie = _mockPipeline.GetSessionCookie(); - var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); authKey.Should().NotBeNull(); - ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); ticket.Should().NotBeNull(); ticket.Principal.GetSubjectId().Should().Be("bob"); - + + // Auth code grant var client1Authorization = await _mockPipeline.RequestAuthorizationEndpointAsync( clientId: "client1", @@ -199,88 +233,416 @@ await _mockPipeline.RequestAuthorizationEndpointAsync( client1Authorization.IsError.Should().BeFalse(); client1Authorization.IdentityToken.Should().BeNull(); client1Authorization.State.Should().Be("state"); - + client1Authorization.Code.Should().NotBeNull(); + + // Verify that the session has been updated with the new client + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + + var clientList = ticket.Properties.GetClientList(); + clientList.Should().Contain("client1"); + + // Exchange code for tokens var tokenClient1 = new TokenClient( _mockPipeline.BackChannelClient!, new TokenClientOptions { Address = IdentityServerPipeline.TokenEndpoint, - ClientId = "client1" + ClientId = "client1", + }); var tokenResponse = await tokenClient1.RequestAuthorizationCodeTokenAsync( code: client1Authorization.Code!, redirectUri: "https://client1/callback", cancellationToken: TestContext.Current.CancellationToken); - + tokenResponse.IsError.Should().BeFalse(); tokenResponse.AccessToken.Should().NotBeNull(); tokenResponse.IdentityToken.Should().NotBeNull(); tokenResponse.RefreshToken.Should().NotBeNull(); - + + // Verify that the session has been updated with the new client ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); ticket.Should().NotBeNull(); ticket.Principal.GetSubjectId().Should().Be("bob"); - - var initialIssuedUtc = ticket.Properties.IssuedUtc; - var initialExpiresUtc = ticket.Properties.ExpiresUtc; - - var clientList = ticket.Properties.GetClientList(); - clientList.Should().Contain("client1"); - + + var client2Authorization = await _mockPipeline.RequestAuthorizationEndpointAsync( clientId: "client2", - responseType: "id_token", + responseType: "code", scope: "openid profile", redirectUri: "https://client2/callback", state: "state2", nonce: "nonce2"); - + client2Authorization.IsError.Should().BeFalse(); - client2Authorization.IdentityToken.Should().NotBeNull(); + client2Authorization.IdentityToken.Should().BeNull(); client2Authorization.State.Should().Be("state2"); - + + // Verify that the session has been updated with the new client ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); ticket.Should().NotBeNull(); ticket.Principal.GetSubjectId().Should().Be("bob"); - + clientList = ticket.Properties.GetClientList(); clientList.Should().Contain("client1"); clientList.Should().Contain("client2"); - - ticket.Properties.IssuedUtc.Should().Be(initialIssuedUtc!.Value); - ticket.Properties.ExpiresUtc.Should().BeAfter(initialExpiresUtc!.Value); - - var tokenClient3 = new TokenClient( + } + + [Fact] + public async Task login_when_introspection_called_expect_session_renewed() + { + //Setup + AuthenticationTicket? ticket = null; + + ticketStore = _mockPipeline.GetTicketStore(); + sessionStore.Should().NotBeNull(); + + await _mockPipeline.LoginAsync("bob"); + + var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); + authKey.Should().NotBeNull(); + + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + + // Get intial issued and expires times after login, before any other clients have been added to the session + var issuedUtc = ticket.Properties.IssuedUtc!.Value; + var expiresUtc = ticket.Properties.ExpiresUtc!.Value; + + fakeTimeProvider.Advance(TimeSpan.FromMinutes(5)); + + // Auth code grant + var client1Authorization = + await _mockPipeline.RequestAuthorizationEndpointAsync( + clientId: "client1", + responseType: "code", + scope: "openid profile api1 offline_access", + redirectUri: "https://client1/callback", + state: "state", + nonce: "nonce"); + + client1Authorization.IsError.Should().BeFalse(); + client1Authorization.IdentityToken.Should().BeNull(); + client1Authorization.State.Should().Be("state"); + client1Authorization.Code.Should().NotBeNull(); + + // Verify that the session has been updated with the new client + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + + // Exchange code for tokens + var client = new TokenClient( _mockPipeline.BackChannelClient!, new TokenClientOptions { Address = IdentityServerPipeline.TokenEndpoint, - ClientId = "client3", - ClientSecret = "secret" + ClientId = "client1", + }); - var accessTokenResponse = - await tokenClient3.RequestClientCredentialsTokenAsync( - scope: "api1 api2", - cancellationToken: TestContext.Current.CancellationToken); + var tokenResponse = await client.RequestAuthorizationCodeTokenAsync( + code: client1Authorization.Code!, + redirectUri: "https://client1/callback", + cancellationToken: TestContext.Current.CancellationToken); - accessTokenResponse.IsError.Should().BeFalse(); - accessTokenResponse.AccessToken.Should().NotBeNull(); - accessTokenResponse.RefreshToken.Should().BeNull(); - + tokenResponse.IsError.Should().BeFalse(); + tokenResponse.AccessToken.Should().NotBeNull(); + tokenResponse.IdentityToken.Should().NotBeNull(); + tokenResponse.RefreshToken.Should().NotBeNull(); + + // Verify that the session has been updated with the new client ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); ticket.Should().NotBeNull(); ticket.Principal.GetSubjectId().Should().Be("bob"); - - clientList = ticket.Properties.GetClientList(); + // Expired is not update on code exchange + ticket.Properties.IssuedUtc.Should().Be(issuedUtc); + ticket.Properties.ExpiresUtc.Should().Be(expiresUtc); + + // Advance time by 5 minutes to simulate time passing + + // Use reference token with introspection to update the session + var introspectionResponse = await _mockPipeline.BackChannelClient! + .IntrospectTokenAsync(new TokenIntrospectionRequest() + { + Address = IdentityServerPipeline.IntrospectionEndpoint, + ClientId = "client1", + Token = tokenResponse.AccessToken + }, TestContext.Current.CancellationToken); + + introspectionResponse.IsError.Should().BeFalse(); + introspectionResponse.IsActive.Should().BeTrue(); + + + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + + ticket.Properties.IssuedUtc.Should().Be(issuedUtc.AddMinutes(5)); + ticket.Properties.ExpiresUtc.Should().BeAfter(expiresUtc); + + } + + [Fact] + public async Task login_when_refresh_token_called_expect_session_renewed() + { + //Setup + AuthenticationTicket? ticket = null; + + ticketStore = _mockPipeline.GetTicketStore(); + sessionStore.Should().NotBeNull(); + + await _mockPipeline.LoginAsync("bob"); + + var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); + authKey.Should().NotBeNull(); + + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + + // Get intial issued and expires times after login, before any other clients have been added to the session + var issuedUtc = ticket.Properties.IssuedUtc!.Value; + var expiresUtc = ticket.Properties.ExpiresUtc!.Value; + + fakeTimeProvider.Advance(TimeSpan.FromMinutes(5)); + + // Auth code grant + var client1Authorization = + await _mockPipeline.RequestAuthorizationEndpointAsync( + clientId: "client1", + responseType: "code", + scope: "openid profile api1 offline_access", + redirectUri: "https://client1/callback", + state: "state", + nonce: "nonce"); + + client1Authorization.IsError.Should().BeFalse(); + client1Authorization.IdentityToken.Should().BeNull(); + client1Authorization.State.Should().Be("state"); + client1Authorization.Code.Should().NotBeNull(); + + // Verify that the session has been updated with the new client + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + + // Exchange code for tokens + var client = new TokenClient( + _mockPipeline.BackChannelClient!, + new TokenClientOptions + { + Address = IdentityServerPipeline.TokenEndpoint, + ClientId = "client1", + + }); + + var tokenResponse = await client.RequestAuthorizationCodeTokenAsync( + code: client1Authorization.Code!, + redirectUri: "https://client1/callback", + cancellationToken: TestContext.Current.CancellationToken); + + tokenResponse.IsError.Should().BeFalse(); + tokenResponse.AccessToken.Should().NotBeNull(); + tokenResponse.IdentityToken.Should().NotBeNull(); + tokenResponse.RefreshToken.Should().NotBeNull(); + + // Verify that the session has been updated with the new client + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + // Expired is not update on code exchange + ticket.Properties.IssuedUtc.Should().Be(issuedUtc); + ticket.Properties.ExpiresUtc.Should().Be(expiresUtc); + + var refreshTokenResponse = await _mockPipeline.BackChannelClient! + .RequestRefreshTokenAsync(new RefreshTokenRequest() + { + Address = IdentityServerPipeline.TokenEndpoint, + ClientId = "client1", + RefreshToken = tokenResponse.RefreshToken + }, TestContext.Current.CancellationToken); + + refreshTokenResponse.IsError.Should().BeFalse(); + refreshTokenResponse.AccessToken.Should().NotBeNull(); + refreshTokenResponse.RefreshToken.Should().NotBeNull(); + + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + ticket.Properties.IssuedUtc.Should().Be(issuedUtc.AddMinutes(5)); + ticket.Properties.ExpiresUtc.Should().BeAfter(expiresUtc); + } + + [Fact] + public async Task login_when_userinfo_called_expect_session_renewed() + { + //Setup + AuthenticationTicket? ticket = null; + + ticketStore = _mockPipeline.GetTicketStore(); + sessionStore.Should().NotBeNull(); + + await _mockPipeline.LoginAsync("bob"); + + var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); + authKey.Should().NotBeNull(); + + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + + // Get intial issued and expires times after login, before any other clients have been added to the session + var issuedUtc = ticket.Properties.IssuedUtc!.Value; + var expiresUtc = ticket.Properties.ExpiresUtc!.Value; + + fakeTimeProvider.Advance(TimeSpan.FromMinutes(5)); + + // Auth code grant + var client1Authorization = + await _mockPipeline.RequestAuthorizationEndpointAsync( + clientId: "client1", + responseType: "code", + scope: "openid profile api1 offline_access", + redirectUri: "https://client1/callback", + state: "state", + nonce: "nonce"); + + client1Authorization.IsError.Should().BeFalse(); + client1Authorization.IdentityToken.Should().BeNull(); + client1Authorization.State.Should().Be("state"); + client1Authorization.Code.Should().NotBeNull(); + + // Verify that the session has been updated with the new client + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + + // Exchange code for tokens + var client = new TokenClient( + _mockPipeline.BackChannelClient!, + new TokenClientOptions + { + Address = IdentityServerPipeline.TokenEndpoint, + ClientId = "client1", + + }); + + var tokenResponse = await client.RequestAuthorizationCodeTokenAsync( + code: client1Authorization.Code!, + redirectUri: "https://client1/callback", + cancellationToken: TestContext.Current.CancellationToken); + + tokenResponse.IsError.Should().BeFalse(); + tokenResponse.AccessToken.Should().NotBeNull(); + tokenResponse.IdentityToken.Should().NotBeNull(); + tokenResponse.RefreshToken.Should().NotBeNull(); + + // Verify that the session has been updated with the new client + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + // Expired is not update on code exchange + ticket.Properties.IssuedUtc.Should().Be(issuedUtc); + ticket.Properties.ExpiresUtc.Should().Be(expiresUtc); + + var userInfoResponse = await _mockPipeline.BackChannelClient! + .GetUserInfoAsync(new UserInfoRequest() + { + Address = IdentityServerPipeline.UserInfoEndpoint, + Token = tokenResponse.AccessToken + }, TestContext.Current.CancellationToken); + + userInfoResponse.IsError.Should().BeFalse(); + + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + ticket.Properties.IssuedUtc.Should().Be(issuedUtc.AddMinutes(5)); + ticket.Properties.ExpiresUtc.Should().BeAfter(expiresUtc); + } + + [Fact] + [Trait("Category", Category)] + public async Task logout_when_multiple_clients_should_render_front_channel_signout_iframes() + { + ticketStore = _mockPipeline.GetTicketStore(); + + await _mockPipeline.LoginAsync("bob"); + var sid = _mockPipeline.GetSessionCookie().Value; + + var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); + authKey.Should().NotBeNull(); + + var client1Authorization = await _mockPipeline.RequestAuthorizationEndpointAsync( + clientId: "client1", + responseType: "code", + scope: "openid profile api1 offline_access", + redirectUri: "https://client1/callback", + state: "state", + nonce: "nonce"); + + client1Authorization.IsError.Should().BeFalse(); + client1Authorization.Code.Should().NotBeNull(); + + var tokenClient1 = new TokenClient( + _mockPipeline.BackChannelClient!, + new TokenClientOptions + { + Address = IdentityServerPipeline.TokenEndpoint, + ClientId = "client1", + }); + + var client1TokenResponse = await tokenClient1.RequestAuthorizationCodeTokenAsync( + code: client1Authorization.Code!, + redirectUri: "https://client1/callback", + cancellationToken: TestContext.Current.CancellationToken); + + client1TokenResponse.IsError.Should().BeFalse(); + client1TokenResponse.IdentityToken.Should().NotBeNull(); + + var client2Authorization = await _mockPipeline.RequestAuthorizationEndpointAsync( + clientId: "client2", + responseType: "code", + scope: "openid profile", + redirectUri: "https://client2/callback", + state: "state2", + nonce: "nonce2"); + + client2Authorization.IsError.Should().BeFalse(); + + var ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + var clientList = ticket!.Properties.GetClientList(); clientList.Should().Contain("client1"); clientList.Should().Contain("client2"); - clientList.Should().NotContain("client3"); - ticket.Properties.IssuedUtc.Should().BeAfter(initialIssuedUtc!.Value); + var endSessionUrl = IdentityServerPipeline.EndSessionEndpoint + + "?id_token_hint=" + Uri.EscapeDataString(client1TokenResponse.IdentityToken!); + + await _mockPipeline.BrowserClient.GetAsync(endSessionUrl, TestContext.Current.CancellationToken); + + _mockPipeline.LogoutWasCalled.Should().BeTrue(); + _mockPipeline.LogoutRequest.Should().NotBeNull(); + _mockPipeline.LogoutRequest.SignOutIFrameUrl.Should().NotBeNull(); + + var signoutFrameResponse = await _mockPipeline.BrowserClient.GetAsync( + _mockPipeline.LogoutRequest.SignOutIFrameUrl, + TestContext.Current.CancellationToken); + signoutFrameResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var html = await signoutFrameResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + html.Should().Contain(HtmlEncoder.Default.Encode( + "https://client1/signout?sid=" + sid + "&iss=" + + UrlEncoder.Default.Encode(IdentityServerPipeline.BaseUrl))); + html.Should().Contain(HtmlEncoder.Default.Encode( + "https://client2/signout?sid=" + sid + "&iss=" + + UrlEncoder.Default.Encode(IdentityServerPipeline.BaseUrl))); } } \ No newline at end of file From 160b556cfc144dda7dfe05e3c58e264891ca927e Mon Sep 17 00:00:00 2001 From: Jo Stevens Date: Mon, 28 Sep 2026 12:06:27 +0100 Subject: [PATCH 53/59] pr: remove uneeded client and comment --- .../Endpoints/Login/LoginServerSideSessionTests.cs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs index fe66e0c89..afe6391c2 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs @@ -45,7 +45,6 @@ public LoginServerSideSessionTests() _mockPipeline.Clients.AddRange([ new Client { - //TODO: Turn this into a ref code client ClientId = "client1", AllowedGrantTypes = GrantTypes.Code, AccessTokenType = AccessTokenType.Reference, @@ -69,15 +68,6 @@ public LoginServerSideSessionTests() RequirePkce = false, RequireClientSecret = false, }, - new Client() - { - //TODO:// turn this in to a code client with non-ref tokens - ClientId = "client3", - AllowedGrantTypes = GrantTypes.ClientCredentials, - AllowedScopes = new List { "api1", "api2" }, - RedirectUris = new List { "https://client3/callback" }, - AllowOfflineAccess = true - } ]); _mockPipeline.Users.Add(new TestUser From e0bf57f84904f606ec0a52677035f508bb4e3d4b Mon Sep 17 00:00:00 2001 From: James Britton Date: Mon, 28 Sep 2026 13:19:57 +0100 Subject: [PATCH 54/59] feat: added issuer property to user session --- ...tionTicketFilterResultMappingExtensions.cs | 37 ++++++++++ .../AuthenticationTicketFilterResult.cs | 19 ----- .../src/Models/UserSession.cs | 5 ++ .../DefaultSessionManagementService.cs | 1 + ...icketFilterResultMappingExtensionsTests.cs | 69 +++++++++++++++++++ .../DefaultSessionManagementServiceTests.cs | 68 +++++++----------- .../Generators/ServerSessionTestGenerators.cs | 29 ++++++++ 7 files changed, 164 insertions(+), 64 deletions(-) create mode 100644 src/Open.IdentityServer/src/Extensions/Mapping/AuthenticationTicketFilterResultMappingExtensions.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Extensions/Mapping/AuthenticationTicketFilterResultMappingExtensionsTests.cs diff --git a/src/Open.IdentityServer/src/Extensions/Mapping/AuthenticationTicketFilterResultMappingExtensions.cs b/src/Open.IdentityServer/src/Extensions/Mapping/AuthenticationTicketFilterResultMappingExtensions.cs new file mode 100644 index 000000000..d9c05e059 --- /dev/null +++ b/src/Open.IdentityServer/src/Extensions/Mapping/AuthenticationTicketFilterResultMappingExtensions.cs @@ -0,0 +1,37 @@ +using System.Linq; +using Open.IdentityServer.Models; + +namespace Open.IdentityServer.Extensions.Mapping; + +/// +/// mapping extension methods +/// +public static class AuthenticationTicketFilterResultMappingExtensions +{ + extension(AuthenticationTicketFilterResult result) + { + /// + /// Maps object to an instance of the model + /// + /// new object + public UserSession ToUserSession() + { + string? issuer = null; + + result.AuthTicket?.Properties.Items.TryGetValue(JwtClaimTypes.Issuer, out issuer); + + return new UserSession + { + SubjectId = result.Session.SubjectId, + SessionId = result.Session.SessionId, + DisplayName = result.Session.DisplayName, + Created = result.Session.Created, + Renewed = result.Session.Renewed, + Expires = result.Session.Expires, + Issuer = issuer, + ClientIds = result.AuthTicket?.Properties.GetClientList().ToList() ?? [], + AuthenticationTicket = result.AuthTicket, + }; + } + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/src/Models/AuthenticationTicketFilterResult.cs b/src/Open.IdentityServer/src/Models/AuthenticationTicketFilterResult.cs index f5f2e335b..1a16be0c1 100644 --- a/src/Open.IdentityServer/src/Models/AuthenticationTicketFilterResult.cs +++ b/src/Open.IdentityServer/src/Models/AuthenticationTicketFilterResult.cs @@ -24,23 +24,4 @@ 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 c95712c0e..a5cdf14dd 100644 --- a/src/Open.IdentityServer/src/Models/UserSession.cs +++ b/src/Open.IdentityServer/src/Models/UserSession.cs @@ -44,6 +44,11 @@ public class UserSession /// public DateTime? Expires { get; set; } + /// + /// Issuer of the auth ticket + /// + public string? Issuer { get; set; } + /// /// Client IDs of clients with active grants and tokens from the session /// diff --git a/src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs b/src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs index 37df5259c..c47c2f1b4 100644 --- a/src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs +++ b/src/Open.IdentityServer/src/Services/Default/DefaultSessionManagementService.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using Open.IdentityServer.Extensions; +using Open.IdentityServer.Extensions.Mapping; using Open.IdentityServer.Models; using Open.IdentityServer.Stores; diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Extensions/Mapping/AuthenticationTicketFilterResultMappingExtensionsTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Extensions/Mapping/AuthenticationTicketFilterResultMappingExtensionsTests.cs new file mode 100644 index 000000000..b1e0818ad --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Extensions/Mapping/AuthenticationTicketFilterResultMappingExtensionsTests.cs @@ -0,0 +1,69 @@ +using AwesomeAssertions; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.Extensions.Mapping; +using Open.IdentityServer.UnitTests.Utilities.Generators; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Extensions.Mapping; + +public class AuthenticationTicketFilterResultMappingExtensionsTests +{ + [Fact] + public void ToUserSession_ShouldMapAuthTicketResultToUserSession() + { + var sut = ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("sess1", "SchemeA", "bob", "session-0001", + "Robert"); + + var actualSession = sut.ToUserSession(); + + actualSession.SubjectId.Should().Be(sut.Session.SubjectId); + actualSession.SessionId.Should().Be(sut.Session.SessionId); + actualSession.DisplayName.Should().Be(sut.Session.DisplayName); + actualSession.Created.Should().Be(sut.Session.Created); + actualSession.Renewed.Should().Be(sut.Session.Renewed); + actualSession.Expires.Should().Be(sut.Session.Expires); + actualSession.AuthenticationTicket.Should().BeEquivalentTo(sut.AuthTicket); + actualSession.Issuer.Should().BeNullOrWhiteSpace(); + actualSession.ClientIds.Should().BeNullOrEmpty(); + } + + [Fact] + public void ToUserSession_WhenClientIdsSet_ShouldExtractClientIdsInResult() + { + var sut = ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("sess1", "SchemeA", "bob", "session-0001", + "Robert", clientIds: ["clientA"]); + + var actualSession = sut.ToUserSession(); + + actualSession.SubjectId.Should().Be(sut.Session.SubjectId); + actualSession.SessionId.Should().Be(sut.Session.SessionId); + actualSession.DisplayName.Should().Be(sut.Session.DisplayName); + actualSession.Created.Should().Be(sut.Session.Created); + actualSession.Renewed.Should().Be(sut.Session.Renewed); + actualSession.Expires.Should().Be(sut.Session.Expires); + actualSession.Issuer.Should().BeNullOrWhiteSpace(); + actualSession.AuthenticationTicket.Should().BeEquivalentTo(sut.AuthTicket); + actualSession.ClientIds.Should().BeEquivalentTo(sut.AuthTicket!.Properties.GetClientList()); + } + + [Fact] + public void ToUserSession_WhenIssuerSet_ShouldExtractIssuerInResult() + { + string fakeIssuer = "https://fakeissuer.com"; + + var sut = ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("sess1", "SchemeA", "bob", "session-0001", + "Robert", clientIds: ["clientA"], issuer: fakeIssuer); + + var actualSession = sut.ToUserSession(); + + actualSession.SubjectId.Should().Be(sut.Session.SubjectId); + actualSession.SessionId.Should().Be(sut.Session.SessionId); + actualSession.DisplayName.Should().Be(sut.Session.DisplayName); + actualSession.Created.Should().Be(sut.Session.Created); + actualSession.Renewed.Should().Be(sut.Session.Renewed); + actualSession.Expires.Should().Be(sut.Session.Expires); + actualSession.Issuer.Should().Be(fakeIssuer); + actualSession.AuthenticationTicket.Should().BeEquivalentTo(sut.AuthTicket); + actualSession.ClientIds.Should().BeEquivalentTo(sut.AuthTicket!.Properties.GetClientList()); + } +} \ 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 index f09f0ae38..e1c7f88fa 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 @@ -77,10 +77,10 @@ public async Task QuerySessionsAsync_WhenResultsReturned_ShouldMapToUserSessionC 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"]), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("sess1","SchemeA", "bob", "session-0001", "Robert", clientIds: ["clientA"]), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("sess2","SchemeA", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("sess3","SchemeB", "bob", "session-0003", "Robert"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("sess4","SchemeB", "sam", "session-0004", "Samantha", clientIds: ["clientA", "clientB"]), ] }; @@ -126,9 +126,9 @@ public async Task RemoveSessionsAsync_WhenFilterSpecified_ShouldRemoveAllSession { List fakeSessions = [ - GenerateAuthenticationTicketFilterResult("key2", "SchemeA", "alice", "session-0002", "Alice"), - GenerateAuthenticationTicketFilterResult("key4", "SchemeB", "alice", "session-0002", "Alice"), - GenerateAuthenticationTicketFilterResult("key7", "SchemeC", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key2", "SchemeA", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key4", "SchemeB", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key7", "SchemeC", "alice", "session-0002", "Alice"), ]; Mock.Get(serverSessionTicketStore) @@ -168,7 +168,7 @@ public async Task RemoveSessionsAsync_WhenFilterSpecified_ShouldRemoveAllSession public async Task RemoveSessionsAsync_WhenClientIdsProvided_ShouldOnlyTriggerBackchannelNotificationsAndRevocationsForThoseClients() { string[] fakeClientIds = ["client-a", "client-b", "client-c", "client-d"]; - var fakeSession = GenerateAuthenticationTicketFilterResult("key2", "SchemeA", "alice", "session-0002", "Alice", clientIds: fakeClientIds); + var fakeSession = ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key2", "SchemeA", "alice", "session-0002", "Alice", clientIds: fakeClientIds); List fakeSessions = [fakeSession]; Mock.Get(serverSessionTicketStore) @@ -207,9 +207,9 @@ public async Task RemoveSessionsAsync_WhenRemoveSessionsSetToFalse_ShouldNotRemo 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"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key2", "SchemeA", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key4", "SchemeB", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key7", "SchemeC", "alice", "session-0002", "Alice"), ]; Mock.Get(serverSessionTicketStore) @@ -251,9 +251,9 @@ public async Task RemoveSessionsAsync_WhenSendBackchannelFalse_ShouldNotSendBack 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"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key2", "SchemeA", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key4", "SchemeB", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key7", "SchemeC", "alice", "session-0002", "Alice"), ]; Mock.Get(serverSessionTicketStore) @@ -288,9 +288,9 @@ public async Task RemoveSessionsAsync_WhenRevokeTokensFalse_ShouldNotRevokeToken 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"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key2", "SchemeA", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key4", "SchemeB", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key7", "SchemeC", "alice", "session-0002", "Alice"), ]; Mock.Get(serverSessionTicketStore) @@ -332,9 +332,9 @@ public async Task RemoveSessionsAsync_WhenRevokeConsentsFalse_ShouldNotRevokeCon 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"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key2", "SchemeA", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key4", "SchemeB", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key7", "SchemeC", "alice", "session-0002", "Alice"), ]; Mock.Get(serverSessionTicketStore) @@ -376,9 +376,9 @@ public async Task RemoveSessionsAsync_WhenRevokeTokensAndConsentsFalse_ShouldNot 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"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key2", "SchemeA", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key4", "SchemeB", "alice", "session-0002", "Alice"), + ServerSessionTestGenerators.GenerateAuthenticationTicketFilterResult("key7", "SchemeC", "alice", "session-0002", "Alice"), ]; Mock.Get(serverSessionTicketStore) @@ -446,26 +446,4 @@ 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/Utilities/Generators/ServerSessionTestGenerators.cs b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Utilities/Generators/ServerSessionTestGenerators.cs index ad324d67c..98d7fb1c0 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Utilities/Generators/ServerSessionTestGenerators.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Utilities/Generators/ServerSessionTestGenerators.cs @@ -19,6 +19,7 @@ public static AuthenticationTicket GenerateAuthenticationTicket( string? displayName = null, DateTimeOffset? issuedUtc = null, DateTimeOffset? expiresUtc = null, + string? issuer = null, string[]? clientIds = null) { IdentityServerUser user = new(subjectId); @@ -35,6 +36,11 @@ public static AuthenticationTicket GenerateAuthenticationTicket( properties.AddClientId(clientId); } + if (issuer != null) + { + properties.Items[JwtClaimTypes.Issuer] = issuer; + } + return new AuthenticationTicket(user.CreatePrincipal(), properties, authScheme); } @@ -106,4 +112,27 @@ public static IdentityServerServerSideSessions FakeSession( Expires = expires ?? new DateTime(2026, 1, 31, 12, 0, 0, DateTimeKind.Utc), }; } + + public static AuthenticationTicketFilterResult GenerateAuthenticationTicketFilterResult( + string key, + string authScheme, + string subjectId, + string sessionId, + string displayName, + DateTime? created = null, + DateTime? renewed = null, + DateTime? expires = null, + string? issuer = 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 = FakeSession(key, authScheme, sessionId, subjectId, displayName, string.Empty, created, renewed, expires), + AuthTicket = GenerateAuthenticationTicket(authScheme, subjectId, sessionId, displayName, renewed, expires, issuer, clientIds), + }; + } } \ No newline at end of file From 47744535694c268eb84237b6df59fe0a3f0c8f61 Mon Sep 17 00:00:00 2001 From: James Britton Date: Mon, 28 Sep 2026 14:46:19 +0100 Subject: [PATCH 55/59] docs: added docs sestion for session management interface --- docs/topics/server_side_sessions.rst | 162 ++++++++++++++++++++++++++- 1 file changed, 158 insertions(+), 4 deletions(-) diff --git a/docs/topics/server_side_sessions.rst b/docs/topics/server_side_sessions.rst index c6838c833..bca7b8914 100644 --- a/docs/topics/server_side_sessions.rst +++ b/docs/topics/server_side_sessions.rst @@ -65,7 +65,161 @@ Getting Started options.ServerSideSessions.RemoveExpiredSessionsBatchSize = 100; }); -Managemet Interface -^^^^^^^^^^^^^^^^^^^ - -TODO: not yet implemented \ No newline at end of file +Management Interface +^^^^^^^^^^^^^^^^^^^^ + +Open.IdentityServer has a built-in session management interface, ``ISessionManagementService``, that allows you to query existing sessions and remove sessions. When you configure Open.IdentityServer to use server-side sessions, a default implementation is registered for this interface. + +The interface provides two methods: + +- ``Task> ISessionManagementService.QuerySessionsAsync(SessionQuery? filter, CancellationToken ct = default)`` + + Filters sessions using the ``SessionQuery`` object. The filter is optional; default values are used when it is not provided. + +- ``Task ISessionManagementService.RemoveSessionsAsync(RemoveSessionsContext context, CancellationToken ct = default)`` + + Removes sessions using the ``RemoveSessionsContext`` object. The context allows you to control which sessions are removed and what actions are taken as part of the removal. + +Data Types +^^^^^^^^^^ + +The following types are used by ``ISessionManagementService`` to query and manage sessions. + +SessionQuery +"""""""""""" + +``SessionQuery`` is used to filter results when calling ``QuerySessionsAsync``. + +.. list-table:: + :header-rows: 1 + :widths: 30 20 50 + + * - Property + - Type + - Description + * - ``ResultsToken`` + - ``string?`` + - Optional selector for current page location, contains identifier for the first element and last element in page of results. e.g. '0,24' + * - ``RequestPriorResults`` + - ``bool`` + - Specifies if instead of getting next page should get the previous page that ResultsToken identifies. Only valid if ResultsToken is specified. + * - ``CountRequested`` + - ``int`` + - Specifies the count requested per page, defaults to 25 + * - ``SubjectId`` + - ``string?`` + - Filters sessions with a specific subject id + * - ``SessionId`` + - ``string?`` + - Filters sessions with a specific session id + * - ``DisplayName`` + - ``string?`` + - Filters sessions with a specific display name + +QueryResult +"""""""""""""" + +``QueryResult`` wraps paged results returned from ``QuerySessionsAsync``. + +.. list-table:: + :header-rows: 1 + :widths: 30 20 50 + + * - Property + - Type + - Description + * - ``ResultsToken`` + - ``string?`` + - Token identifying the current page of results. Contains the first element and last element in page of results. e.g. '0,24' + * - ``HasPrevResults`` + - ``bool`` + - Specifies if there is a page of results before this page + * - ``HasNextResults`` + - ``bool`` + - Specifies if there is a page of results after this page + * - ``TotalCount`` + - ``int?`` + - Total sessions accross all pages of results + * - ``TotalPages`` + - ``int?`` + - Total number of pages for query result + * - ``CurrentPage`` + - ``int?`` + - Page numer of the current result collection + * - ``Results`` + - ``IReadOnlyCollection`` + - The collection of results for the current page + +UserSession +""""""""""" + +``UserSession`` represents a single server-side session record. + +.. list-table:: + :header-rows: 1 + :widths: 30 20 50 + + * - Property + - Type + - Description + * - ``SubjectId`` + - ``string`` + - The subject (user) identifier associated with the session. + * - ``SessionId`` + - ``string`` + - The unique identifier for the session + * - ``DisplayName`` + - ``string?`` + - An optional display name for the user + * - ``Created`` + - ``DateTime`` + - The date and time the session was created. + * - ``Renewed`` + - ``DateTime`` + - The date and time the session was last renewed + * - ``Expires`` + - ``DateTime?`` + - The date and time the session expires, if applicable + * - ``Issuer`` + - ``string?`` + - The issuer of the authentication ticket + * - ``Clients`` + - ``IEnumerable`` + - The clients associated with the session + * - ``AuthenticationTicket`` + - ``AuthenticationTicket?`` + - The user sessions suthentication ticket object + +RemoveSessionsContext +"""""""""""""""""""""" + +``RemoveSessionsContext`` controls which sessions are removed and what side effects occur when calling ``RemoveSessionsAsync``. + +.. list-table:: + :header-rows: 1 + :widths: 30 20 50 + + * - Property + - Type + - Description + * - ``SubjectId`` + - ``string?`` + - Optional if SessionId has value, limits removal to a specific subject id + * - ``SessionId`` + - ``string?`` + - Optional if SubjectId has value, limits removal to a specific session id + * - ``ClientIds`` + - ``IEnumerable?`` + - Optionally limits removal to sessions for specific clients + * - ``RemoveServerSideSession`` + - ``bool`` + - Whether to trigger session entity removal from the database + * - ``SendBackchannelLogoutNotification`` + - ``bool`` + - Whether to trigger backchannel logout notifications for removed sessions + * - ``RevokeTokens`` + - ``bool`` + - Whether to trigger session token removal + * - ``RevokeConsents`` + - ``bool`` + - Whether to trigger session consent removal \ No newline at end of file From fb8417e04c1699286c989531ba39f92efd066c80 Mon Sep 17 00:00:00 2001 From: James Britton Date: Tue, 29 Sep 2026 14:55:02 +0100 Subject: [PATCH 56/59] fix: make session login tests use feature enable flag on pipeline --- .../Default/DefaultUserSessionEventsService.cs | 2 +- .../Common/IdentityServerPipeline.cs | 5 +++-- .../Login/LoginServerSideSessionTests.cs | 16 ++++++++-------- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs b/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs index 4b4e8c302..d5fe669e5 100644 --- a/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs +++ b/src/Open.IdentityServer/src/Services/Default/DefaultUserSessionEventsService.cs @@ -122,7 +122,7 @@ public async Task ValidateSession(ValidateUserSessionEventContext sessionE TimeSpan? diff = session.Session.Expires - session.Session.Renewed; session.Session.Renewed = timeProvider.GetUtcNow().UtcDateTime; session.Session.Expires = session.Session.Renewed + diff; - + if (idsOptions.Authentication.CookieSlidingExpiration && session.AuthTicket?.Properties is { IsPersistent: true, AllowRefresh: true or null }) { diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs index 344753445..4781f528c 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs @@ -81,6 +81,8 @@ public class IdentityServerPipeline public event Action OnPostConfigure = app => { }; public Func>? OnFederatedSignout; + + public AuthenticationProperties? AuthenticationProperties { get; set; } = new(); // Enableable Features public bool EnableServerSideSessions { get; set; } @@ -162,7 +164,6 @@ public void ConfigureServices(IServiceCollection services) .AddInMemoryApiResources(ApiResources) .AddInMemoryApiScopes(ApiScopes) .AddTestUsers(Users) - .AddServerSideSessions() .AddDeveloperSigningCredential(persistKey: false); if (EnableServerSideSessions) @@ -228,7 +229,7 @@ private async Task IssueLoginCookie(HttpContext ctx) { if (Subject != null) { - var props = new AuthenticationProperties(); + var props = AuthenticationProperties ?? new AuthenticationProperties(); await ctx.SignInAsync(Subject, props); Subject = null; var url = ctx.Request.Query[Options!.UserInteraction.LoginReturnUrlParameter].FirstOrDefault(); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs index afe6391c2..110f92d86 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs @@ -31,13 +31,15 @@ public class LoginServerSideSessionTests { private const string Category = "LoginServerSideSessionTests"; - private IdentityServerPipeline _mockPipeline = new IdentityServerPipeline(); - private FakeTimeProvider fakeTimeProvider = new FakeTimeProvider(); - private ITicketStore ticketStore = null; - private IIdentityServerServerSideSessionStore? sessionStore = null; + private IdentityServerPipeline _mockPipeline = new(); + private FakeTimeProvider fakeTimeProvider = new(); + private ITicketStore? ticketStore; + private IIdentityServerServerSideSessionStore? sessionStore; public LoginServerSideSessionTests() { + _mockPipeline.EnableServerSideSessions = true; + fakeTimeProvider.SetUtcNow( new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); @@ -55,7 +57,7 @@ public LoginServerSideSessionTests() AllowOfflineAccess = true, RequirePkce = false, RequireClientSecret = false, - CoordinateLifetimeWithUserSession = true + CoordinateLifetimeWithUserSession = true, }, new Client { @@ -137,8 +139,7 @@ public LoginServerSideSessionTests() IdentityServerConstants.DefaultCookieAuthenticationScheme, options => { options.TimeProvider = fakeTimeProvider; }); }; - - + _mockPipeline.Initialize(); } @@ -146,7 +147,6 @@ public LoginServerSideSessionTests() [Trait("Category", Category)] public async Task Login_ShouldCreateSessionInServerStore() { - sessionStore.Should().NotBeNull(); await _mockPipeline.LoginAsync("bob"); From a7a5c55156253444c7c7a5ebea13098e5ee0b891 Mon Sep 17 00:00:00 2001 From: James Britton Date: Wed, 30 Sep 2026 11:44:09 +0100 Subject: [PATCH 57/59] fix: fixed failing integration tests --- .../AuthenticationTicketSerialiser.cs | 21 +++++++++++++++---- .../Default/ServerSessionTicketStore.cs | 2 +- .../Login/LoginServerSideSessionTests.cs | 14 ++++++++----- .../HttpClientTokenIntrospectionExtensions.cs | 2 ++ 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/Open.IdentityServer/src/Extensions/AuthenticationTicketSerialiser.cs b/src/Open.IdentityServer/src/Extensions/AuthenticationTicketSerialiser.cs index a9e6b2ef6..6e358d6bb 100644 --- a/src/Open.IdentityServer/src/Extensions/AuthenticationTicketSerialiser.cs +++ b/src/Open.IdentityServer/src/Extensions/AuthenticationTicketSerialiser.cs @@ -1,9 +1,11 @@ // 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.Linq; using System.Security.Claims; using Microsoft.AspNetCore.Authentication; +using Open.IdentityServer.Models; using Open.IdentityServer.Stores.Serialization; namespace Open.IdentityServer.Extensions; @@ -25,12 +27,23 @@ public SerializedAuthenticationTicket ToSerializableObj() extension(SerializedAuthenticationTicket serializationAuthTicket) { - public AuthenticationTicket ToAuthTicket() + public AuthenticationTicket ToAuthTicket(IdentityServerServerSideSessions? session) { - return new AuthenticationTicket( - serializationAuthTicket.User.ToClaimsPrincipal(), - new AuthenticationProperties(serializationAuthTicket.Items), + var authTicket = new AuthenticationTicket( + serializationAuthTicket.User.ToClaimsPrincipal(), + new AuthenticationProperties(serializationAuthTicket.Items), serializationAuthTicket.Scheme); + + if (session != null) + { + authTicket.Properties.IssuedUtc = new DateTimeOffset(session.Renewed, TimeSpan.Zero); + if (session.Expires.HasValue) + { + authTicket.Properties.ExpiresUtc = new DateTimeOffset(session.Expires.Value, TimeSpan.Zero); + } + } + + return authTicket; } } } \ 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 7c5d5ddb9..1de5216d5 100644 --- a/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs +++ b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs @@ -212,6 +212,6 @@ private string ToProtectedDataString(AuthenticationTicket ticket) SerializedAuthenticationTicket? serializedAuthTicket = JsonSerializer.Deserialize(unprotectedData); - return serializedAuthTicket?.ToAuthTicket(); + return serializedAuthTicket?.ToAuthTicket(existingSession); } } \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs index 110f92d86..b9bef0996 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs @@ -43,7 +43,6 @@ public LoginServerSideSessionTests() fakeTimeProvider.SetUtcNow( new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); - _mockPipeline.Clients.AddRange([ new Client { @@ -101,11 +100,16 @@ public LoginServerSideSessionTests() new IdentityResources.Profile(), new IdentityResources.Email() ]); + _mockPipeline.ApiResources.AddRange([ new ApiResource { Name = "api", - } + ApiSecrets = [ + new Secret("secret".Sha256()) + ], + Scopes = ["api1", "api2"] + }, ]); _mockPipeline.ApiScopes.AddRange([ new ApiScope @@ -361,14 +365,14 @@ await _mockPipeline.RequestAuthorizationEndpointAsync( .IntrospectTokenAsync(new TokenIntrospectionRequest() { Address = IdentityServerPipeline.IntrospectionEndpoint, - ClientId = "client1", + ClientId = "api", + ClientSecret = "secret", Token = tokenResponse.AccessToken }, TestContext.Current.CancellationToken); introspectionResponse.IsError.Should().BeFalse(); introspectionResponse.IsActive.Should().BeTrue(); - - + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); ticket.Should().NotBeNull(); ticket.Principal.GetSubjectId().Should().Be("bob"); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Utility/Extensions/HttpClientTokenIntrospectionExtensions.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Utility/Extensions/HttpClientTokenIntrospectionExtensions.cs index 7bfb5511d..526a4bb1d 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Utility/Extensions/HttpClientTokenIntrospectionExtensions.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Utility/Extensions/HttpClientTokenIntrospectionExtensions.cs @@ -45,6 +45,8 @@ public static async Task IntrospectTokenAsync(this H return ProtocolResponse.FromException(ex); } + var content = await response.Content.ReadAsStringAsync(); + return await ProtocolResponse.FromHttpResponseAsync(response).ConfigureAwait(); } } From 1f6a288eebdf08ce749f1cdffcdc024d0d20b8f4 Mon Sep 17 00:00:00 2001 From: James Britton Date: Wed, 30 Sep 2026 15:45:51 +0100 Subject: [PATCH 58/59] refactor: server session integration tests to clean them up --- .../AuthoriseServerSideSessionTests.cs | 93 +++ .../EndSessionServerSideSessionTests.cs | 132 ++-- .../Endpoints/EndSession/EndSessionTests.cs | 1 - .../IntrospectionServerSideSessionTests.cs | 81 +++ .../Login/LoginServerSideSessionTests.cs | 583 +----------------- .../Token/TokenServerSideSessionTests.cs | 83 +++ .../Endpoints/UserInfo/UserInfoTests.cs | 78 +++ .../ServerSideSessionTests.cs | 188 ++++++ 8 files changed, 578 insertions(+), 661 deletions(-) create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthoriseServerSideSessionTests.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Introspection/IntrospectionServerSideSessionTests.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Token/TokenServerSideSessionTests.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/UserInfo/UserInfoTests.cs create mode 100644 src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/ServerSideSessionTests.cs diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthoriseServerSideSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthoriseServerSideSessionTests.cs new file mode 100644 index 000000000..3d060a636 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Authorize/AuthoriseServerSideSessionTests.cs @@ -0,0 +1,93 @@ +// 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.Linq; +using System.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.AspNetCore.Authentication; +using Open.IdentityServer.Extensions; +using Xunit; + +namespace Open.IdentityServer.IntegrationTests.Endpoints.Authorize; + +public class AuthoriseServerSideSessionTests: ServerSideSessionTests +{ + private const string Category = nameof(AuthoriseServerSideSessionTests); + + [Fact] + [Trait("Category", Category)] + public async Task AuthorizeEndpoint_WhenCalled_ShouldNotRenewSessionLifetime() + { + //Setup + ticketStore = _mockPipeline.GetTicketStore(); + sessionStore.Should().NotBeNull(); + + // Initial login to create a session + await _mockPipeline.LoginAsync("bob"); + + // Verify that the session has been created in the store + AuthenticationTicket? ticket = null; + var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); + authKey.Should().NotBeNull(); + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + var issuedUtc = ticket.Properties.IssuedUtc!.Value; + var expiresUtc = ticket.Properties.ExpiresUtc!.Value; + + // Auth code grant + await AuthCodeRequest("client1", "openid profile api1 offline_access", "https://client1/callback"); + + // Verify that the session has been updated with the new client + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + ticket.Properties.IssuedUtc.Should().Be(issuedUtc); + ticket.Properties.ExpiresUtc.Should().Be(expiresUtc); + } + + [Fact] + [Trait("Category", Category)] + public async Task AuthorizeEndpoint_WhenCalledWithMultipleClients_ShouldUpdateSessionInServerStore() + { + //Setup + ticketStore = _mockPipeline.GetTicketStore(); + sessionStore.Should().NotBeNull(); + + // Initial login to create a session + await _mockPipeline.LoginAsync("bob"); + + // Verify that the session has been created in the store + AuthenticationTicket? ticket = null; + var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); + authKey.Should().NotBeNull(); + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + + // Auth code grant + await AuthCodeRequest("client1", "openid profile api1 offline_access", "https://client1/callback"); + + // Verify that the session has been updated with the new client + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + + var clientList = ticket.Properties.GetClientList().ToList(); + clientList.Should().NotBeEmpty(); + clientList.Should().Contain("client1"); + + await AuthCodeRequest("client2", "openid profile", "https://client2/callback"); + + // Verify that the session has been updated with the new client + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + + clientList = ticket.Properties.GetClientList().ToList(); + clientList.Should().NotBeEmpty(); + clientList.Should().Contain("client1"); + clientList.Should().Contain("client2"); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionServerSideSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionServerSideSessionTests.cs index 97f3ef67c..d06d79fa6 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionServerSideSessionTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionServerSideSessionTests.cs @@ -3,93 +3,19 @@ #nullable enable -using System.Collections.Generic; +using System; using System.Net; -using System.Security.Claims; +using System.Text.Encodings.Web; using System.Threading.Tasks; using AwesomeAssertions; using IdentityServer.IntegrationTests.Common; -using Microsoft.Extensions.DependencyInjection; -using Open.IdentityServer.Models; -using Open.IdentityServer.Stores; -using Open.IdentityServer.Test; using Xunit; -namespace Open.IdentityServer.IntegrationTests.Endpoints.Login; +namespace Open.IdentityServer.IntegrationTests.Endpoints.EndSession; -public class EndSessionServerSideSessionTests +public class EndSessionServerSideSessionTests: ServerSideSessionTests { - private const string Category = "EndSessionServerSideSessionTests"; - - private IdentityServerPipeline _mockPipeline = new IdentityServerPipeline(); - private IIdentityServerServerSideSessionStore? sessionStore = null; - - public EndSessionServerSideSessionTests() - { - _mockPipeline.EnableServerSideSessions = true; - - _mockPipeline.Clients.AddRange([ - new Client - { - ClientId = "client1", - AllowedGrantTypes = GrantTypes.Implicit, - RequireConsent = false, - AllowedScopes = new List { "openid", "profile" }, - RedirectUris = new List { "https://client1/callback" }, - AllowAccessTokensViaBrowser = true - }, - new Client - { - ClientId = "client2", - AllowedGrantTypes = GrantTypes.Implicit, - RequireConsent = true, - AllowedScopes = new List { "openid", "profile", "api1", "api2" }, - RedirectUris = new List { "https://client2/callback" }, - AllowAccessTokensViaBrowser = true - } - ]); - - _mockPipeline.Users.Add(new TestUser - { - SubjectId = "bob", - Username = "bob", - Claims = - [ - new Claim("name", "Bob Loblaw"), - new Claim("email", "bob@loblaw.com"), - new Claim("role", "Attorney") - ] - }); - - _mockPipeline.IdentityScopes.AddRange([ - new IdentityResources.OpenId(), - new IdentityResources.Profile(), - new IdentityResources.Email() - ]); - _mockPipeline.ApiResources.AddRange([ - new ApiResource - { - Name = "api", - } - ]); - _mockPipeline.ApiScopes.AddRange([ - new ApiScope - { - Name = "api1" - }, - new ApiScope - { - Name = "api2" - } - ]); - - _mockPipeline.OnPreConfigure += app => - { - sessionStore = app.ApplicationServices.GetRequiredService(); - }; - - _mockPipeline.Initialize(); - } + private const string Category = nameof(EndSessionServerSideSessionTests); [Fact] [Trait("Category", Category)] @@ -118,4 +44,52 @@ public async Task EndSession_ShouldRemoveSession() var storedSessionPostEndSession = await sessionStore.GetSession(authKey); storedSessionPostEndSession.Should().BeNull(); } + + [Fact] + [Trait("Category", Category)] + public async Task EndSession_WhenMultipleClients_ShouldRenderFrontChannelSignoutIframes() + { + ticketStore = _mockPipeline.GetTicketStore(); + + await _mockPipeline.LoginAsync("bob"); + var sid = _mockPipeline.GetSessionCookie().Value; + + var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); + authKey.Should().NotBeNull(); + + // Perform Client Authorizations + var (_, client1TokenResponse) = await AuthCodeAndTokenRequest( + "client1", + "openid profile api1 offline_access", + "https://client1/callback"); + + await AuthCodeAndTokenRequest( + "client2", + "openid profile", + "https://client2/callback"); + + var endSessionUrl = IdentityServerPipeline.EndSessionEndpoint + + "?id_token_hint=" + Uri.EscapeDataString(client1TokenResponse.IdentityToken!); + + // Validate End Session Endpoint Behaviour + await _mockPipeline.BrowserClient.GetAsync(endSessionUrl, TestContext.Current.CancellationToken); + + _mockPipeline.LogoutWasCalled.Should().BeTrue(); + _mockPipeline.LogoutRequest.Should().NotBeNull(); + _mockPipeline.LogoutRequest.SignOutIFrameUrl.Should().NotBeNull(); + + var signoutFrameResponse = await _mockPipeline.BrowserClient.GetAsync( + _mockPipeline.LogoutRequest.SignOutIFrameUrl, + TestContext.Current.CancellationToken); + + signoutFrameResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var html = await signoutFrameResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + html.Should().Contain(HtmlEncoder.Default.Encode( + "https://client1/signout?sid=" + sid + "&iss=" + + UrlEncoder.Default.Encode(IdentityServerPipeline.BaseUrl))); + html.Should().Contain(HtmlEncoder.Default.Encode( + "https://client2/signout?sid=" + sid + "&iss=" + + UrlEncoder.Default.Encode(IdentityServerPipeline.BaseUrl))); + } } \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionTests.cs index fb7e2612b..7689d4f7d 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionTests.cs @@ -1,7 +1,6 @@ // Copyright (c) Brock Allen & Dominick Baier. All rights reserved. // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. - using System; using System.Collections.Generic; using System.Linq; diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Introspection/IntrospectionServerSideSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Introspection/IntrospectionServerSideSessionTests.cs new file mode 100644 index 000000000..81485de88 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Introspection/IntrospectionServerSideSessionTests.cs @@ -0,0 +1,81 @@ +// 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.Threading.Tasks; +using AwesomeAssertions; +using IdentityServer.IntegrationTests.Common; +using IdentityServer.IntegrationTests.Utility; +using Microsoft.AspNetCore.Authentication; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.IntegrationTests; +using Xunit; + +namespace IdentityServer.IntegrationTests.Endpoints.Introspection; + +public class IntrospectionServerSideSessionTests: ServerSideSessionTests +{ + private const string Category = nameof(IntrospectionServerSideSessionTests); + + [Fact] + [Trait("Category", Category)] + public async Task Introspection_ShouldRenewSession() + { + //Setup + AuthenticationTicket? ticket; + ticketStore = _mockPipeline.GetTicketStore(); + sessionStore.Should().NotBeNull(); + + await _mockPipeline.LoginAsync("bob"); + + var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); + authKey.Should().NotBeNull(); + + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + + // Get initial issued and expires times after login, before any other clients have been added to the session + var issuedUtc = ticket.Properties.IssuedUtc!.Value; + var expiresUtc = ticket.Properties.ExpiresUtc!.Value; + + fakeTimeProvider.Advance(TimeSpan.FromMinutes(5)); + + var (_, tokenResponse) = await AuthCodeAndTokenRequest( + "client1", + "openid profile api1 offline_access", + "https://client1/callback"); + + tokenResponse.RefreshToken.Should().NotBeNull(); + + // Verify that the session has been updated with the new client + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + // Expired is not updated on code exchange + ticket.Properties.IssuedUtc.Should().Be(issuedUtc); + ticket.Properties.ExpiresUtc.Should().Be(expiresUtc); + + // Use reference token with introspection to update the session + var introspectionResponse = await _mockPipeline.BackChannelClient! + .IntrospectTokenAsync(new TokenIntrospectionRequest() + { + Address = IdentityServerPipeline.IntrospectionEndpoint, + ClientId = "api", + ClientSecret = "secret", + Token = tokenResponse.AccessToken!, + }, TestContext.Current.CancellationToken); + + introspectionResponse.IsError.Should().BeFalse(); + introspectionResponse.IsActive.Should().BeTrue(); + + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + + ticket.Properties.IssuedUtc.Should().Be(issuedUtc.AddMinutes(5)); + ticket.Properties.ExpiresUtc.Should().Be(expiresUtc.AddMinutes(5)); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs index b9bef0996..5be148a7a 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs @@ -3,149 +3,16 @@ #nullable enable -using System; -using System.Collections.Generic; using System.Net; -using System.Security.Claims; -using System.Text.Encodings.Web; using System.Threading.Tasks; using AwesomeAssertions; -using IdentityServer.IntegrationTests.Common; -using IdentityServer.IntegrationTests.Utility; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; -using Microsoft.Extensions.Time.Testing; -using Moq; -using Open.IdentityServer.Configuration; -using Open.IdentityServer.Extensions; -using Open.IdentityServer.Models; -using Open.IdentityServer.Stores; -using Open.IdentityServer.Test; using Xunit; namespace Open.IdentityServer.IntegrationTests.Endpoints.Login; -public class LoginServerSideSessionTests +public class LoginServerSideSessionTests: ServerSideSessionTests { - private const string Category = "LoginServerSideSessionTests"; - - private IdentityServerPipeline _mockPipeline = new(); - private FakeTimeProvider fakeTimeProvider = new(); - private ITicketStore? ticketStore; - private IIdentityServerServerSideSessionStore? sessionStore; - - public LoginServerSideSessionTests() - { - _mockPipeline.EnableServerSideSessions = true; - - fakeTimeProvider.SetUtcNow( - new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); - - _mockPipeline.Clients.AddRange([ - new Client - { - ClientId = "client1", - AllowedGrantTypes = GrantTypes.Code, - AccessTokenType = AccessTokenType.Reference, - RequireConsent = false, - AllowedScopes = new List { "openid", "profile", "api1" }, - RedirectUris = new List { "https://client1/callback" }, - FrontChannelLogoutUri = "https://client1/signout", - AllowOfflineAccess = true, - RequirePkce = false, - RequireClientSecret = false, - CoordinateLifetimeWithUserSession = true, - }, - new Client - { - ClientId = "client2", - AllowedGrantTypes = GrantTypes.Code, - RequireConsent = false, - AllowedScopes = new List { "openid", "profile", "api1", "api2" }, - RedirectUris = new List { "https://client2/callback" }, - FrontChannelLogoutUri = "https://client2/signout", - RequirePkce = false, - RequireClientSecret = false, - }, - ]); - - _mockPipeline.Users.Add(new TestUser - { - SubjectId = "bob", - Username = "bob", - Claims = - [ - new Claim("name", "Bob Loblaw"), - new Claim("email", "bob@loblaw.com"), - new Claim("role", "Attorney") - ] - }); - - _mockPipeline.Users.Add(new TestUser - { - SubjectId = "alice", - Username = "alice", - Claims = - [ - new Claim("name", "Alice Smith"), - new Claim("alice", "alice@smith.com"), - new Claim("role", "Attorney") - ] - }); - - _mockPipeline.IdentityScopes.AddRange([ - new IdentityResources.OpenId(), - new IdentityResources.Profile(), - new IdentityResources.Email() - ]); - - _mockPipeline.ApiResources.AddRange([ - new ApiResource - { - Name = "api", - ApiSecrets = [ - new Secret("secret".Sha256()) - ], - Scopes = ["api1", "api2"] - }, - ]); - _mockPipeline.ApiScopes.AddRange([ - new ApiScope - { - Name = "api1" - }, - new ApiScope - { - Name = "api2" - } - ]); - - _mockPipeline.OnPreConfigure += app => - { - sessionStore = app.ApplicationServices.GetRequiredService(); - }; - - _mockPipeline.OnPostConfigureServices += services => - { - services.Configure(options => - { - //Session Expirey is only update if more than half of the cookie lifetime has passed, - //so we set the cookie lifetime to 6 minutes, then update the TimeProvider by 5 minutes each step. - options.Authentication.CookieLifetime = TimeSpan.FromMinutes(6); - options.Authentication.CookieSlidingExpiration = true; - }); - - services.AddSingleton(fakeTimeProvider); - - services.PostConfigure( - IdentityServerConstants.DefaultCookieAuthenticationScheme, - options => { options.TimeProvider = fakeTimeProvider; }); - }; - - _mockPipeline.Initialize(); - } + private const string Category = nameof(LoginServerSideSessionTests); [Fact] [Trait("Category", Category)] @@ -193,450 +60,4 @@ public async Task Login_WhenUserChanges_ShouldUpdateSessionInServerStore() updatedSession.SessionId.Should().Be(newSessionCookie.Value); updatedSession.SubjectId.Should().Be("alice"); } - - [Fact] - [Trait("Category", Category)] - public async Task login_when_multiple_clients_should_update_session_in_server_store() - { - - //Setup - ticketStore = _mockPipeline.GetTicketStore(); - sessionStore.Should().NotBeNull(); - - // Initial login to create a session - await _mockPipeline.LoginAsync("bob"); - - // Verify that the session has been created in the store - AuthenticationTicket? ticket = null; - var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); - authKey.Should().NotBeNull(); - ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); - ticket.Should().NotBeNull(); - ticket.Principal.GetSubjectId().Should().Be("bob"); - - // Auth code grant - var client1Authorization = - await _mockPipeline.RequestAuthorizationEndpointAsync( - clientId: "client1", - responseType: "code", - scope: "openid profile api1 offline_access", - redirectUri: "https://client1/callback", - state: "state", - nonce: "nonce"); - - client1Authorization.IsError.Should().BeFalse(); - client1Authorization.IdentityToken.Should().BeNull(); - client1Authorization.State.Should().Be("state"); - client1Authorization.Code.Should().NotBeNull(); - - // Verify that the session has been updated with the new client - ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); - ticket.Should().NotBeNull(); - ticket.Principal.GetSubjectId().Should().Be("bob"); - - var clientList = ticket.Properties.GetClientList(); - clientList.Should().Contain("client1"); - - // Exchange code for tokens - var tokenClient1 = new TokenClient( - _mockPipeline.BackChannelClient!, - new TokenClientOptions - { - Address = IdentityServerPipeline.TokenEndpoint, - ClientId = "client1", - - }); - - var tokenResponse = await tokenClient1.RequestAuthorizationCodeTokenAsync( - code: client1Authorization.Code!, - redirectUri: "https://client1/callback", - cancellationToken: TestContext.Current.CancellationToken); - - tokenResponse.IsError.Should().BeFalse(); - tokenResponse.AccessToken.Should().NotBeNull(); - tokenResponse.IdentityToken.Should().NotBeNull(); - tokenResponse.RefreshToken.Should().NotBeNull(); - - // Verify that the session has been updated with the new client - ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); - ticket.Should().NotBeNull(); - ticket.Principal.GetSubjectId().Should().Be("bob"); - - - var client2Authorization = - await _mockPipeline.RequestAuthorizationEndpointAsync( - clientId: "client2", - responseType: "code", - scope: "openid profile", - redirectUri: "https://client2/callback", - state: "state2", - nonce: "nonce2"); - - client2Authorization.IsError.Should().BeFalse(); - client2Authorization.IdentityToken.Should().BeNull(); - client2Authorization.State.Should().Be("state2"); - - // Verify that the session has been updated with the new client - ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); - ticket.Should().NotBeNull(); - ticket.Principal.GetSubjectId().Should().Be("bob"); - - clientList = ticket.Properties.GetClientList(); - clientList.Should().Contain("client1"); - clientList.Should().Contain("client2"); - } - - [Fact] - public async Task login_when_introspection_called_expect_session_renewed() - { - //Setup - AuthenticationTicket? ticket = null; - - ticketStore = _mockPipeline.GetTicketStore(); - sessionStore.Should().NotBeNull(); - - await _mockPipeline.LoginAsync("bob"); - - var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); - authKey.Should().NotBeNull(); - - ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); - ticket.Should().NotBeNull(); - ticket.Principal.GetSubjectId().Should().Be("bob"); - - // Get intial issued and expires times after login, before any other clients have been added to the session - var issuedUtc = ticket.Properties.IssuedUtc!.Value; - var expiresUtc = ticket.Properties.ExpiresUtc!.Value; - - fakeTimeProvider.Advance(TimeSpan.FromMinutes(5)); - - // Auth code grant - var client1Authorization = - await _mockPipeline.RequestAuthorizationEndpointAsync( - clientId: "client1", - responseType: "code", - scope: "openid profile api1 offline_access", - redirectUri: "https://client1/callback", - state: "state", - nonce: "nonce"); - - client1Authorization.IsError.Should().BeFalse(); - client1Authorization.IdentityToken.Should().BeNull(); - client1Authorization.State.Should().Be("state"); - client1Authorization.Code.Should().NotBeNull(); - - // Verify that the session has been updated with the new client - ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); - ticket.Should().NotBeNull(); - ticket.Principal.GetSubjectId().Should().Be("bob"); - - // Exchange code for tokens - var client = new TokenClient( - _mockPipeline.BackChannelClient!, - new TokenClientOptions - { - Address = IdentityServerPipeline.TokenEndpoint, - ClientId = "client1", - - }); - - var tokenResponse = await client.RequestAuthorizationCodeTokenAsync( - code: client1Authorization.Code!, - redirectUri: "https://client1/callback", - cancellationToken: TestContext.Current.CancellationToken); - - tokenResponse.IsError.Should().BeFalse(); - tokenResponse.AccessToken.Should().NotBeNull(); - tokenResponse.IdentityToken.Should().NotBeNull(); - tokenResponse.RefreshToken.Should().NotBeNull(); - - // Verify that the session has been updated with the new client - ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); - ticket.Should().NotBeNull(); - ticket.Principal.GetSubjectId().Should().Be("bob"); - // Expired is not update on code exchange - ticket.Properties.IssuedUtc.Should().Be(issuedUtc); - ticket.Properties.ExpiresUtc.Should().Be(expiresUtc); - - // Advance time by 5 minutes to simulate time passing - - // Use reference token with introspection to update the session - var introspectionResponse = await _mockPipeline.BackChannelClient! - .IntrospectTokenAsync(new TokenIntrospectionRequest() - { - Address = IdentityServerPipeline.IntrospectionEndpoint, - ClientId = "api", - ClientSecret = "secret", - Token = tokenResponse.AccessToken - }, TestContext.Current.CancellationToken); - - introspectionResponse.IsError.Should().BeFalse(); - introspectionResponse.IsActive.Should().BeTrue(); - - ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); - ticket.Should().NotBeNull(); - ticket.Principal.GetSubjectId().Should().Be("bob"); - - ticket.Properties.IssuedUtc.Should().Be(issuedUtc.AddMinutes(5)); - ticket.Properties.ExpiresUtc.Should().BeAfter(expiresUtc); - - } - - [Fact] - public async Task login_when_refresh_token_called_expect_session_renewed() - { - //Setup - AuthenticationTicket? ticket = null; - - ticketStore = _mockPipeline.GetTicketStore(); - sessionStore.Should().NotBeNull(); - - await _mockPipeline.LoginAsync("bob"); - - var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); - authKey.Should().NotBeNull(); - - ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); - ticket.Should().NotBeNull(); - ticket.Principal.GetSubjectId().Should().Be("bob"); - - // Get intial issued and expires times after login, before any other clients have been added to the session - var issuedUtc = ticket.Properties.IssuedUtc!.Value; - var expiresUtc = ticket.Properties.ExpiresUtc!.Value; - - fakeTimeProvider.Advance(TimeSpan.FromMinutes(5)); - - // Auth code grant - var client1Authorization = - await _mockPipeline.RequestAuthorizationEndpointAsync( - clientId: "client1", - responseType: "code", - scope: "openid profile api1 offline_access", - redirectUri: "https://client1/callback", - state: "state", - nonce: "nonce"); - - client1Authorization.IsError.Should().BeFalse(); - client1Authorization.IdentityToken.Should().BeNull(); - client1Authorization.State.Should().Be("state"); - client1Authorization.Code.Should().NotBeNull(); - - // Verify that the session has been updated with the new client - ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); - ticket.Should().NotBeNull(); - ticket.Principal.GetSubjectId().Should().Be("bob"); - - // Exchange code for tokens - var client = new TokenClient( - _mockPipeline.BackChannelClient!, - new TokenClientOptions - { - Address = IdentityServerPipeline.TokenEndpoint, - ClientId = "client1", - - }); - - var tokenResponse = await client.RequestAuthorizationCodeTokenAsync( - code: client1Authorization.Code!, - redirectUri: "https://client1/callback", - cancellationToken: TestContext.Current.CancellationToken); - - tokenResponse.IsError.Should().BeFalse(); - tokenResponse.AccessToken.Should().NotBeNull(); - tokenResponse.IdentityToken.Should().NotBeNull(); - tokenResponse.RefreshToken.Should().NotBeNull(); - - // Verify that the session has been updated with the new client - ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); - ticket.Should().NotBeNull(); - ticket.Principal.GetSubjectId().Should().Be("bob"); - // Expired is not update on code exchange - ticket.Properties.IssuedUtc.Should().Be(issuedUtc); - ticket.Properties.ExpiresUtc.Should().Be(expiresUtc); - - var refreshTokenResponse = await _mockPipeline.BackChannelClient! - .RequestRefreshTokenAsync(new RefreshTokenRequest() - { - Address = IdentityServerPipeline.TokenEndpoint, - ClientId = "client1", - RefreshToken = tokenResponse.RefreshToken - }, TestContext.Current.CancellationToken); - - refreshTokenResponse.IsError.Should().BeFalse(); - refreshTokenResponse.AccessToken.Should().NotBeNull(); - refreshTokenResponse.RefreshToken.Should().NotBeNull(); - - ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); - ticket.Should().NotBeNull(); - ticket.Principal.GetSubjectId().Should().Be("bob"); - ticket.Properties.IssuedUtc.Should().Be(issuedUtc.AddMinutes(5)); - ticket.Properties.ExpiresUtc.Should().BeAfter(expiresUtc); - } - - [Fact] - public async Task login_when_userinfo_called_expect_session_renewed() - { - //Setup - AuthenticationTicket? ticket = null; - - ticketStore = _mockPipeline.GetTicketStore(); - sessionStore.Should().NotBeNull(); - - await _mockPipeline.LoginAsync("bob"); - - var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); - authKey.Should().NotBeNull(); - - ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); - ticket.Should().NotBeNull(); - ticket.Principal.GetSubjectId().Should().Be("bob"); - - // Get intial issued and expires times after login, before any other clients have been added to the session - var issuedUtc = ticket.Properties.IssuedUtc!.Value; - var expiresUtc = ticket.Properties.ExpiresUtc!.Value; - - fakeTimeProvider.Advance(TimeSpan.FromMinutes(5)); - - // Auth code grant - var client1Authorization = - await _mockPipeline.RequestAuthorizationEndpointAsync( - clientId: "client1", - responseType: "code", - scope: "openid profile api1 offline_access", - redirectUri: "https://client1/callback", - state: "state", - nonce: "nonce"); - - client1Authorization.IsError.Should().BeFalse(); - client1Authorization.IdentityToken.Should().BeNull(); - client1Authorization.State.Should().Be("state"); - client1Authorization.Code.Should().NotBeNull(); - - // Verify that the session has been updated with the new client - ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); - ticket.Should().NotBeNull(); - ticket.Principal.GetSubjectId().Should().Be("bob"); - - // Exchange code for tokens - var client = new TokenClient( - _mockPipeline.BackChannelClient!, - new TokenClientOptions - { - Address = IdentityServerPipeline.TokenEndpoint, - ClientId = "client1", - - }); - - var tokenResponse = await client.RequestAuthorizationCodeTokenAsync( - code: client1Authorization.Code!, - redirectUri: "https://client1/callback", - cancellationToken: TestContext.Current.CancellationToken); - - tokenResponse.IsError.Should().BeFalse(); - tokenResponse.AccessToken.Should().NotBeNull(); - tokenResponse.IdentityToken.Should().NotBeNull(); - tokenResponse.RefreshToken.Should().NotBeNull(); - - // Verify that the session has been updated with the new client - ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); - ticket.Should().NotBeNull(); - ticket.Principal.GetSubjectId().Should().Be("bob"); - // Expired is not update on code exchange - ticket.Properties.IssuedUtc.Should().Be(issuedUtc); - ticket.Properties.ExpiresUtc.Should().Be(expiresUtc); - - var userInfoResponse = await _mockPipeline.BackChannelClient! - .GetUserInfoAsync(new UserInfoRequest() - { - Address = IdentityServerPipeline.UserInfoEndpoint, - Token = tokenResponse.AccessToken - }, TestContext.Current.CancellationToken); - - userInfoResponse.IsError.Should().BeFalse(); - - ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); - ticket.Should().NotBeNull(); - ticket.Principal.GetSubjectId().Should().Be("bob"); - ticket.Properties.IssuedUtc.Should().Be(issuedUtc.AddMinutes(5)); - ticket.Properties.ExpiresUtc.Should().BeAfter(expiresUtc); - } - - [Fact] - [Trait("Category", Category)] - public async Task logout_when_multiple_clients_should_render_front_channel_signout_iframes() - { - ticketStore = _mockPipeline.GetTicketStore(); - - await _mockPipeline.LoginAsync("bob"); - var sid = _mockPipeline.GetSessionCookie().Value; - - var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); - authKey.Should().NotBeNull(); - - var client1Authorization = await _mockPipeline.RequestAuthorizationEndpointAsync( - clientId: "client1", - responseType: "code", - scope: "openid profile api1 offline_access", - redirectUri: "https://client1/callback", - state: "state", - nonce: "nonce"); - - client1Authorization.IsError.Should().BeFalse(); - client1Authorization.Code.Should().NotBeNull(); - - var tokenClient1 = new TokenClient( - _mockPipeline.BackChannelClient!, - new TokenClientOptions - { - Address = IdentityServerPipeline.TokenEndpoint, - ClientId = "client1", - }); - - var client1TokenResponse = await tokenClient1.RequestAuthorizationCodeTokenAsync( - code: client1Authorization.Code!, - redirectUri: "https://client1/callback", - cancellationToken: TestContext.Current.CancellationToken); - - client1TokenResponse.IsError.Should().BeFalse(); - client1TokenResponse.IdentityToken.Should().NotBeNull(); - - var client2Authorization = await _mockPipeline.RequestAuthorizationEndpointAsync( - clientId: "client2", - responseType: "code", - scope: "openid profile", - redirectUri: "https://client2/callback", - state: "state2", - nonce: "nonce2"); - - client2Authorization.IsError.Should().BeFalse(); - - var ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); - ticket.Should().NotBeNull(); - var clientList = ticket!.Properties.GetClientList(); - clientList.Should().Contain("client1"); - clientList.Should().Contain("client2"); - - var endSessionUrl = IdentityServerPipeline.EndSessionEndpoint + - "?id_token_hint=" + Uri.EscapeDataString(client1TokenResponse.IdentityToken!); - - await _mockPipeline.BrowserClient.GetAsync(endSessionUrl, TestContext.Current.CancellationToken); - - _mockPipeline.LogoutWasCalled.Should().BeTrue(); - _mockPipeline.LogoutRequest.Should().NotBeNull(); - _mockPipeline.LogoutRequest.SignOutIFrameUrl.Should().NotBeNull(); - - var signoutFrameResponse = await _mockPipeline.BrowserClient.GetAsync( - _mockPipeline.LogoutRequest.SignOutIFrameUrl, - TestContext.Current.CancellationToken); - - signoutFrameResponse.StatusCode.Should().Be(HttpStatusCode.OK); - var html = await signoutFrameResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - - html.Should().Contain(HtmlEncoder.Default.Encode( - "https://client1/signout?sid=" + sid + "&iss=" + - UrlEncoder.Default.Encode(IdentityServerPipeline.BaseUrl))); - html.Should().Contain(HtmlEncoder.Default.Encode( - "https://client2/signout?sid=" + sid + "&iss=" + - UrlEncoder.Default.Encode(IdentityServerPipeline.BaseUrl))); - } } \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Token/TokenServerSideSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Token/TokenServerSideSessionTests.cs new file mode 100644 index 000000000..79ece0693 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Token/TokenServerSideSessionTests.cs @@ -0,0 +1,83 @@ +// 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.Threading.Tasks; +using AwesomeAssertions; +using IdentityServer.IntegrationTests.Common; +using IdentityServer.IntegrationTests.Utility; +using Microsoft.AspNetCore.Authentication; +using Open.IdentityServer.Extensions; +using Xunit; + +namespace Open.IdentityServer.IntegrationTests.Endpoints.Token; + +public class TokenServerSideSessionTests: ServerSideSessionTests +{ + private const string Category = nameof(TokenServerSideSessionTests); + + [Fact] + [Trait("Category", Category)] + public async Task TokenEndpoint_WhenRefreshTokenExchanged_ShouldRenewSession() + { + //Setup + AuthenticationTicket? ticket = null; + + ticketStore = _mockPipeline.GetTicketStore(); + sessionStore.Should().NotBeNull(); + + await _mockPipeline.LoginAsync("bob"); + + var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); + authKey.Should().NotBeNull(); + + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + + // Get initial issued and expires times after login, before any other clients have been added to the session + var issuedUtc = ticket.Properties.IssuedUtc!.Value; + var expiresUtc = ticket.Properties.ExpiresUtc!.Value; + + fakeTimeProvider.Advance(TimeSpan.FromMinutes(5)); + + var (_, tokenResponse) = await AuthCodeAndTokenRequest( + "client1", + "openid profile api1 offline_access", + "https://client1/callback"); + + tokenResponse.IsError.Should().BeFalse(); + tokenResponse.AccessToken.Should().NotBeNull(); + tokenResponse.IdentityToken.Should().NotBeNull(); + tokenResponse.RefreshToken.Should().NotBeNull(); + + // Verify that the session lifetime hasn't changed + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + ticket.Properties.IssuedUtc.Should().Be(issuedUtc); + ticket.Properties.ExpiresUtc.Should().Be(expiresUtc); + + // Exchange refresh token + var refreshTokenResponse = await _mockPipeline.BackChannelClient! + .RequestRefreshTokenAsync(new RefreshTokenRequest() + { + Address = IdentityServerPipeline.TokenEndpoint, + ClientId = "client1", + RefreshToken = tokenResponse.RefreshToken + }, TestContext.Current.CancellationToken); + + refreshTokenResponse.IsError.Should().BeFalse(); + refreshTokenResponse.AccessToken.Should().NotBeNull(); + refreshTokenResponse.RefreshToken.Should().NotBeNull(); + + // Verify session lifetime had been updated + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + ticket.Properties.IssuedUtc.Should().Be(issuedUtc.AddMinutes(5)); + ticket.Properties.ExpiresUtc.Should().Be(expiresUtc.AddMinutes(5)); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/UserInfo/UserInfoTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/UserInfo/UserInfoTests.cs new file mode 100644 index 000000000..f705943b3 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/UserInfo/UserInfoTests.cs @@ -0,0 +1,78 @@ +// 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.Threading.Tasks; +using AwesomeAssertions; +using IdentityServer.IntegrationTests.Common; +using IdentityServer.IntegrationTests.Utility; +using Microsoft.AspNetCore.Authentication; +using Open.IdentityServer.Extensions; +using Xunit; + +namespace Open.IdentityServer.IntegrationTests.Endpoints.UserInfo; + +public class UserInfoTests: ServerSideSessionTests +{ + private const string Category = nameof(UserInfoTests); + + [Fact] + [Trait("Category", Category)] + public async Task UserInfo_WhenCalled_ShouldRenewSession() + { + //Setup + AuthenticationTicket? ticket = null; + + ticketStore = _mockPipeline.GetTicketStore(); + sessionStore.Should().NotBeNull(); + + await _mockPipeline.LoginAsync("bob"); + + var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); + authKey.Should().NotBeNull(); + + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + + // Get initial issued and expires times after login, before any other clients have been added to the session + var issuedUtc = ticket.Properties.IssuedUtc!.Value; + var expiresUtc = ticket.Properties.ExpiresUtc!.Value; + + fakeTimeProvider.Advance(TimeSpan.FromMinutes(5)); + + // Auth code grant + var (_, tokenResponse) = await AuthCodeAndTokenRequest( + "client1", + "openid profile api1 offline_access", + "https://client1/callback"); + + tokenResponse.AccessToken.Should().NotBeNull(); + + // Verify that the session lifetime hasn't changed + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + ticket.Properties.IssuedUtc.Should().Be(issuedUtc); + ticket.Properties.ExpiresUtc.Should().Be(expiresUtc); + + // Perform user info requests + var userInfoResponse = await _mockPipeline.BackChannelClient! + .GetUserInfoAsync(new UserInfoRequest() + { + Address = IdentityServerPipeline.UserInfoEndpoint, + Token = tokenResponse.AccessToken + }, TestContext.Current.CancellationToken); + + userInfoResponse.IsError.Should().BeFalse(); + + //Verify that session lifetime has been updated + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Principal.GetSubjectId().Should().Be("bob"); + ticket.Properties.IssuedUtc.Should().Be(issuedUtc.AddMinutes(5)); + ticket.Properties.ExpiresUtc.Should().Be(expiresUtc.AddMinutes(5)); + } +} \ No newline at end of file diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/ServerSideSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/ServerSideSessionTests.cs new file mode 100644 index 000000000..8f166b52c --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/ServerSideSessionTests.cs @@ -0,0 +1,188 @@ +// 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 System.Security.Claims; +using System.Threading.Tasks; +using AwesomeAssertions; +using IdentityServer.IntegrationTests.Common; +using IdentityServer.IntegrationTests.Utility; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Time.Testing; +using Open.IdentityServer.Configuration; +using Open.IdentityServer.Models; +using Open.IdentityServer.Stores; +using Open.IdentityServer.Test; +using Xunit; + +namespace Open.IdentityServer.IntegrationTests; + +public abstract class ServerSideSessionTests +{ + protected IdentityServerPipeline _mockPipeline = new(); + protected FakeTimeProvider fakeTimeProvider = new(); + protected ITicketStore? ticketStore; + protected IIdentityServerServerSideSessionStore? sessionStore; + + protected ServerSideSessionTests() + { + _mockPipeline.EnableServerSideSessions = true; + + fakeTimeProvider.SetUtcNow( + new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); + + _mockPipeline.Clients.AddRange([ + new Client + { + ClientId = "client1", + AllowedGrantTypes = GrantTypes.Code, + AccessTokenType = AccessTokenType.Reference, + RequireConsent = false, + AllowedScopes = new List { "openid", "profile", "api1" }, + RedirectUris = new List { "https://client1/callback" }, + FrontChannelLogoutUri = "https://client1/signout", + AllowOfflineAccess = true, + RequirePkce = false, + RequireClientSecret = false, + CoordinateLifetimeWithUserSession = true, + }, + new Client + { + ClientId = "client2", + AllowedGrantTypes = GrantTypes.Code, + RequireConsent = false, + AllowedScopes = new List { "openid", "profile", "api1", "api2" }, + RedirectUris = new List { "https://client2/callback" }, + FrontChannelLogoutUri = "https://client2/signout", + RequirePkce = false, + RequireClientSecret = false, + }, + ]); + + _mockPipeline.Users.Add(new TestUser + { + SubjectId = "bob", + Username = "bob", + Claims = + [ + new Claim("name", "Bob Loblaw"), + new Claim("email", "bob@loblaw.com"), + new Claim("role", "Attorney") + ] + }); + + _mockPipeline.Users.Add(new TestUser + { + SubjectId = "alice", + Username = "alice", + Claims = + [ + new Claim("name", "Alice Smith"), + new Claim("alice", "alice@smith.com"), + new Claim("role", "Attorney") + ] + }); + + _mockPipeline.IdentityScopes.AddRange([ + new IdentityResources.OpenId(), + new IdentityResources.Profile(), + new IdentityResources.Email() + ]); + + _mockPipeline.ApiResources.AddRange([ + new ApiResource + { + Name = "api", + ApiSecrets = [ + new Secret("secret".Sha256()) + ], + Scopes = ["api1", "api2"] + }, + ]); + _mockPipeline.ApiScopes.AddRange([ + new ApiScope + { + Name = "api1" + }, + new ApiScope + { + Name = "api2" + } + ]); + + _mockPipeline.OnPreConfigure += app => + { + sessionStore = app.ApplicationServices.GetRequiredService(); + }; + + _mockPipeline.OnPostConfigureServices += services => + { + services.Configure(options => + { + //Session Expirey is only update if more than half of the cookie lifetime has passed, + //so we set the cookie lifetime to 6 minutes, then update the TimeProvider by 5 minutes each step. + options.Authentication.CookieLifetime = TimeSpan.FromMinutes(6); + options.Authentication.CookieSlidingExpiration = true; + }); + + services.AddSingleton(fakeTimeProvider); + + services.PostConfigure( + IdentityServerConstants.DefaultCookieAuthenticationScheme, + options => { options.TimeProvider = fakeTimeProvider; }); + }; + + _mockPipeline.Initialize(); + } + + protected async Task<(AuthorizeResponse, TokenResponse)> AuthCodeAndTokenRequest(string clientId, string scope, string redirectUri) + { + var codeResponse = await AuthCodeRequest(clientId, scope, redirectUri); + var tokenResponse = await AuthCodeTokenRequest(clientId, redirectUri, codeResponse.Code!); + return (codeResponse, tokenResponse); + } + + protected async Task AuthCodeRequest(string clientId, string scope, string redirectUri) + { + var authResponse = await _mockPipeline.RequestAuthorizationEndpointAsync( + clientId: clientId, + responseType: "code", + scope: scope, + redirectUri: redirectUri, + state: $"state-{clientId}", + nonce: $"nonce-{clientId}"); + + authResponse.IsError.Should().BeFalse(); + authResponse.IdentityToken.Should().BeNull(); + authResponse.State.Should().Be($"state-{clientId}"); + authResponse.Code.Should().NotBeNull(); + + return authResponse; + } + + protected async Task AuthCodeTokenRequest(string clientId, string redirectUri, string code) + { + var tokenClient1 = new TokenClient( + _mockPipeline.BackChannelClient!, + new TokenClientOptions + { + Address = IdentityServerPipeline.TokenEndpoint, + ClientId = clientId, + }); + + var tokenResponse = await tokenClient1.RequestAuthorizationCodeTokenAsync( + code: code, + redirectUri: redirectUri, + cancellationToken: TestContext.Current.CancellationToken); + + tokenResponse.IsError.Should().BeFalse(); + tokenResponse.AccessToken.Should().NotBeNull(); + tokenResponse.IdentityToken.Should().NotBeNull(); + + return tokenResponse; + } +} \ No newline at end of file From 81f8ecf9ade13e90c61e5746dbc31deec4d6a942 Mon Sep 17 00:00:00 2001 From: James Britton Date: Thu, 1 Oct 2026 12:14:51 +0100 Subject: [PATCH 59/59] fix: added issuer to auth ticket when storing new ticket --- .../Default/ServerSessionTicketStore.cs | 12 +++++- .../Login/LoginServerSideSessionTests.cs | 22 +++++++++++ .../Default/ServerSessionTicketStoreTests.cs | 39 ++++++++++++++++--- 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs index 31947bd37..d5ed3660a 100644 --- a/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs +++ b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs @@ -13,6 +13,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Open.IdentityServer.Configuration; using Open.IdentityServer.DataProtection; @@ -30,7 +31,8 @@ namespace Open.IdentityServer.Stores; /// /// data protection provider /// time provider -/// identit server options +/// identity server options +/// http context accessor /// telemetry service /// the logger public class ServerSessionTicketStore( @@ -38,6 +40,7 @@ public class ServerSessionTicketStore( IDataProtectionProvider dataProtectionProvider, TimeProvider timeProvider, IdentityServerOptions options, + IHttpContextAccessor httpContextAccessor, ITelemetryService telemetry, ILogger logger): IServerSessionTicketStore { @@ -177,6 +180,13 @@ public async Task> GetAndRemoveExp private async Task StoreNewSession(string key, AuthenticationTicket ticket) { + var issuerUri = httpContextAccessor.HttpContext.GetIdentityServerIssuerUri(); + + if (issuerUri != null) + { + ticket.Properties.Items[JwtClaimTypes.Issuer] = issuerUri; + } + string? displayName = string.IsNullOrWhiteSpace(options.ServerSideSessions.UserDisplayNameClaimType) ? null : ticket.Principal.FindFirstValue(options.ServerSideSessions.UserDisplayNameClaimType); diff --git a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs index 5be148a7a..aba231fba 100644 --- a/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs @@ -6,6 +6,8 @@ using System.Net; using System.Threading.Tasks; using AwesomeAssertions; +using IdentityServer.IntegrationTests.Common; +using Microsoft.AspNetCore.Authentication; using Xunit; namespace Open.IdentityServer.IntegrationTests.Endpoints.Login; @@ -60,4 +62,24 @@ public async Task Login_WhenUserChanges_ShouldUpdateSessionInServerStore() updatedSession.SessionId.Should().Be(newSessionCookie.Value); updatedSession.SubjectId.Should().Be("alice"); } + + [Fact] + [Trait("Category", Category)] + public async Task Login_ShouldStoreAuthTicketWithIssuerAttached() + { + //Setup + ticketStore = _mockPipeline.GetTicketStore(); + sessionStore.Should().NotBeNull(); + + // Initial login to create a session + await _mockPipeline.LoginAsync("bob"); + + // Verify that the session has been created in the store + AuthenticationTicket? ticket = null; + var authKey = _mockPipeline.GetTicketStoreKeyFromAuthCookie(); + authKey.Should().NotBeNull(); + ticket = await ticketStore.RetrieveAsync(authKey, TestContext.Current.CancellationToken); + ticket.Should().NotBeNull(); + ticket.Properties.Items[JwtClaimTypes.Issuer].Should().Be(IdentityServerPipeline.BaseUrl); + } } \ 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 65fee6f07..7a0273457 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 @@ -12,6 +12,7 @@ using AwesomeAssertions; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Time.Testing; using Moq; using Open.IdentityServer.Configuration; @@ -35,17 +36,23 @@ public class ServerSessionTicketStoreTests private readonly IDataProtectionProvider dataProtectionProvider = Mock.Of(); private readonly MockDataProtector dataProtector = new(); private readonly FakeTimeProvider fakeTimeProvider = new(); + private readonly IHttpContextAccessor httpContextAccessor = Mock.Of(); private readonly ITelemetryService telemetry = Mock.Of(); private readonly MockLogger logger = new(); private readonly IdentityServerOptions fakeOptions = new(); + private readonly IServiceProvider fakeserviceProvider = Mock.Of(); + private readonly HttpContext fakeHttpContext = new DefaultHttpContext(); private static readonly DateTime FakeNow = new(2026, 01, 01, 12, 0, 0, DateTimeKind.Utc); + private static readonly string FakeIssuer = "https://fake.issuer.com"; public ServerSessionTicketStoreTests() { fakeTimeProvider.SetUtcNow(FakeNow); - + fakeOptions.IssuerUri = FakeIssuer; + fakeHttpContext.RequestServices = fakeserviceProvider; + Mock.Get(dataProtectionProvider) .Setup(x => x.CreateProtector(DataProtectionConstants.ServerSideTicketStorePurpose)) .Returns(dataProtector); @@ -53,10 +60,18 @@ public ServerSessionTicketStoreTests() Mock.Get(serverServerSideSessionStore) .Setup(x => x.FilterSessions(It.IsAny())) .ReturnsAsync(QueryResult.Empty); + + Mock.Get(fakeserviceProvider) + .Setup(x => x.GetService(typeof(IdentityServerOptions))) + .Returns(fakeOptions); + + Mock.Get(httpContextAccessor) + .Setup(x => x.HttpContext) + .Returns(fakeHttpContext); } private ServerSessionTicketStore CreateSut() => new(serverServerSideSessionStore, dataProtectionProvider, - fakeTimeProvider, fakeOptions, telemetry, logger); + fakeTimeProvider, fakeOptions, httpContextAccessor, telemetry, logger); [Fact] public async Task StoreAsync_WhenOptionalValuesNotProvided_ShouldUseCorrectDefaults() @@ -93,7 +108,10 @@ public async Task StoreAsync_WhenOptionalValuesNotProvided_ShouldUseCorrectDefau var actualPayload = jsonElement.GetProperty("Payload").GetString(); actualPayload.Should().NotBeNull(); - string expectedJson = JsonSerializer.Serialize(authenticationTicket.ToSerializableObj(), + var expectedAuthTicket = authenticationTicket.Clone(); + expectedAuthTicket.Properties.Items[JwtClaimTypes.Issuer] = FakeIssuer; + + string expectedJson = JsonSerializer.Serialize(expectedAuthTicket.ToSerializableObj(), ServerSessionTicketStore.JsonSettings); dataProtector.ValidateProtectedData(actualPayload, expectedJson); } @@ -136,8 +154,11 @@ public async Task StoreAsync_WhenOptionalValuesProvided_ShouldUseThem() jsonElement.GetProperty("Version").GetInt32().Should().Be(1); var actualPayload = jsonElement.GetProperty("Payload").GetString(); actualPayload.Should().NotBeNull(); + + var expectedAuthTicket = authenticationTicket.Clone(); + expectedAuthTicket.Properties.Items[JwtClaimTypes.Issuer] = FakeIssuer; - string expectedJson = JsonSerializer.Serialize(authenticationTicket.ToSerializableObj(), + string expectedJson = JsonSerializer.Serialize(expectedAuthTicket.ToSerializableObj(), ServerSessionTicketStore.JsonSettings); dataProtector.ValidateProtectedData(actualPayload, expectedJson); } @@ -184,8 +205,11 @@ public async Task StoreAsync_WhenDisplayNameClaimSet_ShouldUseClaimValueIfSet(st jsonElement.GetProperty("Version").GetInt32().Should().Be(1); var actualPayload = jsonElement.GetProperty("Payload").GetString(); actualPayload.Should().NotBeNull(); + + var expectedAuthTicket = authenticationTicket.Clone(); + expectedAuthTicket.Properties.Items[JwtClaimTypes.Issuer] = FakeIssuer; - string expectedJson = JsonSerializer.Serialize(authenticationTicket.ToSerializableObj(), + string expectedJson = JsonSerializer.Serialize(expectedAuthTicket.ToSerializableObj(), ServerSessionTicketStore.JsonSettings); dataProtector.ValidateProtectedData(actualPayload, expectedJson); } @@ -338,7 +362,10 @@ public async Task RenewAsync_WhenNoExistingSessionWithKey_ShouldCreateNewSession var actualPayload = jsonElement.GetProperty("Payload").GetString(); actualPayload.Should().NotBeNull(); - string expectedJson = JsonSerializer.Serialize(authenticationTicket.ToSerializableObj(), + var expectedAuthTicket = authenticationTicket.Clone(); + expectedAuthTicket.Properties.Items[JwtClaimTypes.Issuer] = FakeIssuer; + + string expectedJson = JsonSerializer.Serialize(expectedAuthTicket.ToSerializableObj(), ServerSessionTicketStore.JsonSettings); dataProtector.ValidateProtectedData(actualPayload, expectedJson); }