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..ee7eb6e5a --- /dev/null +++ b/src/EntityFramework.Storage/src/Stores/Compatibility/IdentityServerServerSideSessionStore.cs @@ -0,0 +1,124 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +#nullable enable + +using System; +using System.Threading.Tasks; +using Microsoft.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 +/// +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 + .SingleOrDefaultAsync(x => x.Key == key); + + return session?.ToModel(); + } + + /// + public async Task CreateSession(IdentityServerServerSideSessions session) + { + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + + ArgumentException.ThrowIfNullOrWhiteSpace(session.Key); + + Entities.IdentityServerServerSideSessions? existing = await dbContext.ServerSideSessions + .SingleOrDefaultAsync(x => x.Key == session.Key); + + if (existing != null) + { + logger.LogError("failed storing '{SessionKey}' session in database, session with key already exists", session.Key); + 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) + { + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + + ArgumentException.ThrowIfNullOrWhiteSpace(session.Key); + + Entities.IdentityServerServerSideSessions? existing = await dbContext.ServerSideSessions + .SingleOrDefaultAsync(x => x.Key == session.Key); + + if (existing == null) + { + logger.LogError("failed 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) + { + using var trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + Entities.IdentityServerServerSideSessions? existing = await dbContext.ServerSideSessions + .SingleOrDefaultAsync(x => x.Key == key); + + if (existing == null) + { + logger.LogError("failed deleting '{SessionKey}' session in database, session not found", key); + 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..748c82b26 --- /dev/null +++ b/src/EntityFramework.Storage/test/IntegrationTests/MockLogger.cs @@ -0,0 +1,52 @@ +// 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; + +namespace Open.IdentityServer.EntityFramework.IntegrationTests; + +public class MockLogger : ILogger +{ + private readonly ILogger _mock = Mock.Of>(); + + public static MockLogger 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..b0207b40c --- /dev/null +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/Compatibility/IdentityServerServerSideSessionStoreTests.cs @@ -0,0 +1,366 @@ +using System; +using System.Collections.Generic; +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 Open.IdentityServer.Services; +using Xunit; +using SessionModel = Open.IdentityServer.Models.IdentityServerServerSideSessions; + +namespace Open.IdentityServer.EntityFramework.IntegrationTests.Stores.Compatibility; + +public class IdentityServerServerSideSessionStoreTests: IntegrationTest +{ + private readonly ITelemetryService telemetry = Mock.Of(); + private readonly MockLogger fakeLogger = new(); + + public IdentityServerServerSideSessionStoreTests(DatabaseProviderFixture fixture) : base(fixture) + { + foreach (TheoryDataRow> row in TestDatabaseProviders) + { + using PersistedGrantDbContext context = new PersistedGrantDbContext(row.Data, StoreOptions); + context.Database.EnsureCreated(); + } + } + + private IdentityServerServerSideSessionStore CreateSut(PersistedGrantDbContext dbContext) => + new(dbContext, telemetry, fakeLogger); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task GetSession_WhenKeyNullOrEmpty_ShouldThrowArgumentException(string key) + { + await using var context = await CreateCleanContext(TestDatabaseProviders.FirstOrDefault()); + IdentityServerServerSideSessionStore 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); + IdentityServerServerSideSessionStore sut = CreateSut(context); + + SessionModel 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); + + string key = "session-key-1"; + IdentityServerServerSideSessions 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(); + + IdentityServerServerSideSessionStore sut = CreateSut(context); + + SessionModel 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()); + IdentityServerServerSideSessionStore 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); + + string 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(); + + IdentityServerServerSideSessionStore 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); + + string key = "new-key"; + var session = BuildSessionModel(key, "sub-123", "sid-123", "display-123"); + + IdentityServerServerSideSessionStore 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()); + IdentityServerServerSideSessionStore 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); + IdentityServerServerSideSessionStore 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); + + string 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); + + IdentityServerServerSideSessionStore 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()); + IdentityServerServerSideSessionStore 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); + IdentityServerServerSideSessionStore 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); + + string 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(); + + IdentityServerServerSideSessionStore 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(); + } + + [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) + { + PersistedGrantDbContext 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/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..fa525a375 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,20 @@ 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(); + + // provide default in-memory implementation, not suitable for most production scenarios (following pattern implemented with existing stores) + builder.Services.TryAddSingleton(); + + 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/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/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..a9e6b2ef6 --- /dev/null +++ b/src/Open.IdentityServer/src/Extensions/AuthenticationTicketSerialiser.cs @@ -0,0 +1,36 @@ +// 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; + +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, + }; + } + } + + 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 23604f338..cb0e08c5f 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,24 @@ 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(); + } + + 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 new file mode 100644 index 000000000..514db0d41 --- /dev/null +++ b/src/Open.IdentityServer/src/Extensions/ClaimsPrincipleExtension.cs @@ -0,0 +1,36 @@ +// 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(), + }; + } + } + + extension(ClaimsPrincipalLite claimsPrincipalLite) + { + public ClaimsPrincipal ToClaimsPrincipal() + { + 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/src/Stores/Default/ServerSessionTicketStore.cs b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs new file mode 100644 index 000000000..ebb49f65b --- /dev/null +++ b/src/Open.IdentityServer/src/Stores/Default/ServerSessionTicketStore.cs @@ -0,0 +1,178 @@ +// 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.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.DataProtection; +using Open.IdentityServer.Extensions; +using Open.IdentityServer.Models; +using Open.IdentityServer.Services; +using Open.IdentityServer.Stores.Serialization; + +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, + TimeProvider timeProvider, + ITelemetryService telemetry, + ILogger logger) : ITicketStore +{ + private readonly IDataProtector dataProtector = + dataProtectionProvider.CreateProtector(DataProtectionConstants.ServerSideTicketStorePurpose); + + /// + /// to be used for storing server side sessions + /// + public static readonly JsonSerializerOptions JsonSettings = new() + { + IncludeFields = true, + }; + + /// + public async Task StoreAsync(AuthenticationTicket ticket) + { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + + string key = Guid.NewGuid().ToString(); + trace?.AddTag(TelemetryConstants.TagConstants.Key, key); + + IdentityServerServerSideSessions session = await StoreNewSession(key, ticket); + trace?.AddTag(TelemetryConstants.TagConstants.Subject, session.SubjectId); + trace?.AddTag(TelemetryConstants.TagConstants.Session, session.SessionId); + + return session.Key; + } + + /// + 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); + + IdentityServerServerSideSessions? existingSession = await serverServerSideSessionStore.GetSession(key); + + if (existingSession == null) + { + logger.LogInformation("failed renewing '{SessionKey}' session in database, session with key doesn't exists", key); + await StoreNewSession(key, ticket); + return; + } + + string? subjectId = ticket.Principal.GetSubjectId(); + string? sessionId = ticket.Properties.GetSessionId(); + trace?.AddTag(TelemetryConstants.TagConstants.Subject, subjectId); + trace?.AddTag(TelemetryConstants.TagConstants.Session, sessionId); + + existingSession.Scheme = ticket.AuthenticationScheme; + existingSession.SubjectId = subjectId; + existingSession.SessionId = sessionId; + existingSession.DisplayName = ticket.Principal.FindFirstValue(JwtClaimTypes.Name); + existingSession.Renewed = ticket.Properties.IssuedUtc?.UtcDateTime ?? timeProvider.GetUtcNow().UtcDateTime; + existingSession.Expires = ticket.Properties.ExpiresUtc?.UtcDateTime; + existingSession.Data = ToProtectedDataString(ticket); + + await serverServerSideSessionStore.UpdateSession(existingSession); + } + + /// + public async Task RetrieveAsync(string key) + { + using ITrace? trace = telemetry.Trace(TelemetryConstants.TraceCategories.Stores, this); + trace?.AddTag(TelemetryConstants.TagConstants.Key, key); + + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + IdentityServerServerSideSessions? existingSession = await serverServerSideSessionStore.GetSession(key); + + if (existingSession == null) + { + logger.LogInformation("session with key '{SessionKey}' doesn't exist", key); + return null; + } + + try + { + DataProtectedSessionData? dataProtectedSessionData = JsonSerializer.Deserialize(existingSession.Data, JsonSettings); + + if (dataProtectedSessionData is not { Version: 1 }) + { + logger.LogError("failed retrieving '{SessionKey}', deserialisation failed, incorrect version '{VersionOrNull}'", key, dataProtectedSessionData?.Version); + return null; + } + + string unprotectedData = dataProtector.Unprotect(dataProtectedSessionData.Payload); + + SerializedAuthenticationTicket? serializedAuthTicket = + JsonSerializer.Deserialize(unprotectedData); + + return serializedAuthTicket?.ToAuthTicket(); + } + catch (Exception ex) + { + logger.LogError(ex, "failed retrieving '{SessionKey}' session in database", key); + return null; + } + } + + /// + 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; + } + + 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/src/Stores/InMemory/InMemorySessionStore.cs b/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs new file mode 100644 index 000000000..65b80a228 --- /dev/null +++ b/src/Open.IdentityServer/src/Stores/InMemory/InMemorySessionStore.cs @@ -0,0 +1,57 @@ +// 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; +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.IntegrationTests/Common/IdentityServerPipeline.cs b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Common/IdentityServerPipeline.cs index f03d6b190..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 @@ -27,6 +28,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 +56,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(); @@ -75,6 +80,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 +139,7 @@ public void ConfigureServices(IServiceCollection services) return handler; }); - services.AddIdentityServer(options => + var idsBuilder = services.AddIdentityServer(options => { Options = options; @@ -150,6 +158,11 @@ public void ConfigureServices(IServiceCollection services) .AddTestUsers(Users) .AddDeveloperSigningCredential(persistKey: false); + if (EnableServerSideSessions) + { + idsBuilder.AddServerSideSessions(); + } + services.AddHttpClient(IdentityServerConstants.HttpClients.BackChannelLogoutHttpClient) .AddHttpMessageHandler(() => BackChannelMessageHandler); @@ -291,7 +304,7 @@ public async Task LoginAsync(ClaimsPrincipal subject) Subject = subject; await BrowserClient.GetAsync(LoginPage); - + BrowserClient.AllowAutoRedirect = old; } @@ -313,6 +326,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/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/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 new file mode 100644 index 000000000..97f3ef67c --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/EndSession/EndSessionServerSideSessionTests.cs @@ -0,0 +1,121 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +#nullable enable + +using System.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..ee1bfac15 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.IntegrationTests/Endpoints/Login/LoginServerSideSessionTests.cs @@ -0,0 +1,152 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +#nullable enable + +using System.Collections.Generic; +using System.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 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/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/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/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/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 new file mode 100644 index 000000000..0231022e6 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/Default/ServerSessionTicketStoreTests.cs @@ -0,0 +1,509 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using AwesomeAssertions; +using Microsoft.AspNetCore.Authentication; +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; +using Open.IdentityServer.Services; +using Open.IdentityServer.Stores; +using Open.IdentityServer.Stores.Serialization; +using Xunit; + +namespace Open.IdentityServer.UnitTests.Stores.Default; + +public class ServerSessionTicketStoreTests +{ + 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); + + public ServerSessionTicketStoreTests() + { + fakeTimeProvider.SetUtcNow(FakeNow); + + Mock.Get(dataProtectionProvider) + .Setup(x => x.CreateProtector(DataProtectionConstants.ServerSideTicketStorePurpose)) + .Returns(dataProtector); + } + + private ServerSessionTicketStore CreateSut() => new(serverServerSideSessionStore, dataProtectionProvider, + fakeTimeProvider, telemetry, 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(); + + var jsonElement = JsonElement.Parse(createdSessionModel.Data); + + jsonElement.GetProperty("Version").GetInt32().Should().Be(1); + var actualPayload = jsonElement.GetProperty("Payload").GetString(); + actualPayload.Should().NotBeNull(); + + string expectedJson = JsonSerializer.Serialize(authenticationTicket.ToSerializableObj(), + ServerSessionTicketStore.JsonSettings); + dataProtector.ValidateProtectedData(actualPayload, expectedJson); + } + + [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); + + var jsonElement = JsonElement.Parse(createdSessionModel.Data); + + jsonElement.GetProperty("Version").GetInt32().Should().Be(1); + var actualPayload = jsonElement.GetProperty("Payload").GetString(); + actualPayload.Should().NotBeNull(); + + string expectedJson = JsonSerializer.Serialize(authenticationTicket.ToSerializableObj(), + ServerSessionTicketStore.JsonSettings); + dataProtector.ValidateProtectedData(actualPayload, expectedJson); + } + + [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(); + + var jsonElement = JsonElement.Parse(createdSessionModel.Data); + + jsonElement.GetProperty("Version").GetInt32().Should().Be(1); + var actualPayload = jsonElement.GetProperty("Payload").GetString(); + actualPayload.Should().NotBeNull(); + + string expectedJson = JsonSerializer.Serialize(authenticationTicket.ToSerializableObj(), + ServerSessionTicketStore.JsonSettings); + dataProtector.ValidateProtectedData(actualPayload, expectedJson); + } + + [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); + + IdentityServerServerSideSessions? updatedSessionModel = null; + Mock.Get(serverServerSideSessionStore) + .Setup(x => x.UpdateSession(It.IsAny())) + .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); + + 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(actualPayload, 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().BeNull(); + createdSessionModel.Created.Should().Be(FakeNow); + 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(actualPayload, 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(); + } + + [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() + { + 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 = GenerateFakeData(authenticationTicket); + + 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.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); + } + + [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)); + } + + [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 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) + { + 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 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..db18c1d70 --- /dev/null +++ b/src/Open.IdentityServer/test/Open.IdentityServer.UnitTests/Stores/InMemorySessionStoreTests.cs @@ -0,0 +1,212 @@ +// 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.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) => + seedDictionary == null ? + new InMemorySessionStore() : + new InMemorySessionStore(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 diff --git a/src/Storage/src/Models/Compatibility/IdentityServerServerSideSessions.cs b/src/Storage/src/Models/Compatibility/IdentityServerServerSideSessions.cs new file mode 100644 index 000000000..67995e71d --- /dev/null +++ b/src/Storage/src/Models/Compatibility/IdentityServerServerSideSessions.cs @@ -0,0 +1,59 @@ +// 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; + +/// +/// 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..6518e09a3 --- /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-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 provided 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 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 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