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