From 35e541427f0d32f9acf110a888e2ca625705f51a Mon Sep 17 00:00:00 2001 From: James Britton Date: Mon, 20 Jul 2026 11:09:42 +0100 Subject: [PATCH 01/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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/13] 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;