From 7de8b6d69636d38f58b1405b8174ebed62b106b5 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sun, 6 Sep 2026 11:01:53 +0200 Subject: [PATCH 1/6] feat: Implemented full extensibility of ApiScope model (not the entity). Includes tests. --- .../src/Mappers/ScopeMappingExtensions.cs | 12 +++- .../src/Stores/ResourceStore.cs | 15 ++++- .../Stores/ExtendedApiScope.cs | 17 ++++++ .../Stores/ExtendedResourceStore.cs | 26 ++++++++ .../Stores/ResourceStoreTests.cs | 27 +++++++++ .../UnitTests/Mappers/ScopeMappersTests.cs | 59 +++++++++++++++++++ 6 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedApiScope.cs create mode 100644 src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedResourceStore.cs diff --git a/src/EntityFramework.Storage/src/Mappers/ScopeMappingExtensions.cs b/src/EntityFramework.Storage/src/Mappers/ScopeMappingExtensions.cs index f869874fa..b6a30d78e 100644 --- a/src/EntityFramework.Storage/src/Mappers/ScopeMappingExtensions.cs +++ b/src/EntityFramework.Storage/src/Mappers/ScopeMappingExtensions.cs @@ -20,7 +20,17 @@ public static class ScopeMappingExtensions /// mapped instance of public Models.ApiScope ToModel() { - return new Models.ApiScope + return apiScopeEntity.ToModel(); + } + + /// + /// Mapper for to convert into an instance of + /// + /// mapped instance of + public T ToModel() + where T : Models.ApiScope, new() + { + return new T { Enabled = apiScopeEntity.Enabled, Name = apiScopeEntity.Name, diff --git a/src/EntityFramework.Storage/src/Stores/ResourceStore.cs b/src/EntityFramework.Storage/src/Stores/ResourceStore.cs index 55920a2df..a9e07e59f 100644 --- a/src/EntityFramework.Storage/src/Stores/ResourceStore.cs +++ b/src/EntityFramework.Storage/src/Stores/ResourceStore.cs @@ -183,7 +183,20 @@ where scopes.Contains(scope.Name) Logger.LogDebug("Found {scopes} scopes in database", results.Select(x => x.Name)); - return results.Select(x => x.ToModel()).ToArray(); + return results.Select(ToApiScopeModel).ToArray(); + } + + /// + /// Maps the to the . + /// + /// The . + /// The or an object extending ApiScope. + /// + /// Makes it possible to return an extended model. + /// + protected virtual ApiScope ToApiScopeModel(Entities.ApiScope scope) + { + return scope.ToModel(); } /// diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedApiScope.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedApiScope.cs new file mode 100644 index 000000000..721853fb4 --- /dev/null +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedApiScope.cs @@ -0,0 +1,17 @@ +using Open.IdentityServer.Models; +using System; + +namespace Open.IdentityServer.EntityFramework.IntegrationTests.Stores; + +internal class ExtendedApiScope : ApiScope +{ + public DateTime Created { get; set; } + public DateTime? Updated { get; set; } + public DateTime? LastAccessed { get; set; } + + public string? X + { + get => Properties.ContainsKey("x") ? Properties["x"] : null; + set => Properties["x"] = value; + } +} \ No newline at end of file diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedResourceStore.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedResourceStore.cs new file mode 100644 index 000000000..0c6595c30 --- /dev/null +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedResourceStore.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Logging; +using Open.IdentityServer.EntityFramework.Interfaces; +using Open.IdentityServer.EntityFramework.Mappers; +using Open.IdentityServer.EntityFramework.Stores; +using Open.IdentityServer.Services; + +namespace Open.IdentityServer.EntityFramework.IntegrationTests.Stores; + +internal class ExtendedResourceStore : ResourceStore +{ + public ExtendedResourceStore(IConfigurationDbContext context, ITelemetryService telemetry, ILogger logger) + : base(context, telemetry, logger) + { + } + + protected override Models.ApiScope ToApiScopeModel(Entities.ApiScope scope) + { + var model = scope.ToModel(); + + model.Created = scope.Created; + model.Updated = scope.Updated; + model.LastAccessed = scope.LastAccessed; + + return model; + } +} diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ResourceStoreTests.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ResourceStoreTests.cs index 6eac4ae9e..66d3bcd4b 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ResourceStoreTests.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ResourceStoreTests.cs @@ -313,6 +313,33 @@ public async Task FindApiScopesByNameAsync_WhenResourcesExist_ExpectOnlyRequeste Assert.NotNull(resources.Single(x => x.Name == resource.Name)); } + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FindApiScopesByNameAsync_WhenResourcesExist_ExpectExtendedApiScopeReturned(DbContextOptions options) + { + var resource = CreateApiScopeTestResource(); + resource.Properties.Add("x", "xx"); + + using (var context = new ConfigurationDbContext(options, StoreOptions)) + { + context.ApiScopes.Add(resource.ToEntity()); + context.SaveChanges(); + } + + IList resources; + using (var context = new ConfigurationDbContext(options, StoreOptions)) + { + var store = new ExtendedResourceStore(context, _telemetry, FakeLogger.Create()); + resources = (await store.FindApiScopesByNameAsync(new List + { + resource.Name + })).ToList(); + } + + Assert.NotNull(resources); + var extendedApiScope = resources.SingleOrDefault(x => x.Name == resource.Name) as ExtendedApiScope; + Assert.NotNull(extendedApiScope); + } + [Theory, MemberData(nameof(TestDatabaseProviders))] public async Task GetAllResources_WhenAllResourcesRequested_ExpectAllResourcesIncludingHidden(DbContextOptions options) { diff --git a/src/EntityFramework.Storage/test/UnitTests/Mappers/ScopeMappersTests.cs b/src/EntityFramework.Storage/test/UnitTests/Mappers/ScopeMappersTests.cs index b53af4969..bf4e1dbc7 100644 --- a/src/EntityFramework.Storage/test/UnitTests/Mappers/ScopeMappersTests.cs +++ b/src/EntityFramework.Storage/test/UnitTests/Mappers/ScopeMappersTests.cs @@ -3,6 +3,7 @@ // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. +using System; using System.Linq; using AwesomeAssertions; using Open.IdentityServer.EntityFramework.Mappers; @@ -24,6 +25,30 @@ public void CanMapScope() Assert.NotNull(mappedEntity); } + [Fact] + public void CanMapToExtendedScopeModel() + { + var entity = new Entities.ApiScope + { + Name = "foo", + DisplayName = "foo", + Description = "bar", + Created = DateTime.UtcNow.AddDays(-100), + Updated = DateTime.UtcNow.AddDays(-50), + LastAccessed = DateTime.UtcNow.AddDays(-10), + UserClaims = [ new Entities.ApiScopeClaim { Type = "c1" }, new Entities.ApiScopeClaim { Type = "c2" } ], + Properties = [ new Entities.ApiScopeProperty { Key = "x", Value = "xx" }, new Entities.ApiScopeProperty { Key = "y", Value = "yy" } ] + }; + + var model = entity.ToExtendedModel(); + + Assert.NotNull(model); + model.Created.Should().Be(entity.Created); + model.Updated.Should().Be(entity.Updated); + model.LastAccessed.Should().Be(entity.LastAccessed); + model.X.Should().Be(entity.Properties.Single(p => p.Key == "x").Value); + } + [Fact] public void Properties_Map() { @@ -86,4 +111,38 @@ public void ToModel_maps_all_properties() new MappingVerifier() .Verify(entity => entity.ToModel()); } +} + +internal static class ExtendedScopeMappingExtensions +{ + extension(Entities.ApiScope apiScopeEntity) + { + /// + /// Mapper for to convert into an instance of + /// + /// mapped instance of + public ExtendedApiScope ToExtendedModel() + { + var model = apiScopeEntity.ToModel(); + model.Created = apiScopeEntity.Created; + model.Updated = apiScopeEntity.Updated; + model.LastAccessed = apiScopeEntity.LastAccessed; + + return model; + } + + } +} + +internal class ExtendedApiScope : ApiScope +{ + public DateTime Created { get; set; } + public DateTime? Updated { get; set; } + public DateTime? LastAccessed { get; set; } + + public string? X + { + get => Properties.ContainsKey("x") ? Properties["x"] : null; + set => Properties["x"] = value; + } } \ No newline at end of file From 94bc0d94d583fac37fc515861941848548726b04 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sun, 6 Sep 2026 11:34:55 +0200 Subject: [PATCH 2/6] feat: Implemented full extensibility of ApiResource model (not the entity). Includes tests. --- .../Mappers/ApiResourceMappingExtensions.cs | 13 +++- .../src/Mappers/ScopeMappingExtensions.cs | 5 +- .../src/Stores/ResourceStore.cs | 23 +++++-- .../Stores/ExtendedApiResource.cs | 17 +++++ .../Stores/ExtendedResourceStore.cs | 11 ++++ .../Stores/ResourceStoreTests.cs | 66 +++++++++++++++++-- .../Mappers/ApiResourceMappersTests.cs | 62 ++++++++++++++++- 7 files changed, 180 insertions(+), 17 deletions(-) create mode 100644 src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedApiResource.cs diff --git a/src/EntityFramework.Storage/src/Mappers/ApiResourceMappingExtensions.cs b/src/EntityFramework.Storage/src/Mappers/ApiResourceMappingExtensions.cs index 187243b7a..643c86e92 100644 --- a/src/EntityFramework.Storage/src/Mappers/ApiResourceMappingExtensions.cs +++ b/src/EntityFramework.Storage/src/Mappers/ApiResourceMappingExtensions.cs @@ -22,7 +22,18 @@ public static class ApiResourceMappingExtensions /// mapped instance of public Models.ApiResource ToModel() { - return new Models.ApiResource + return apiResourceEntity.ToModel(); + } + + /// + /// Mapper for to convert into an instance of . + /// + /// The type of model to map to. + /// mapped instance of . + public T ToModel() + where T : Models.ApiResource, new() + { + return new T { Enabled = apiResourceEntity.Enabled, Name = apiResourceEntity.Name, diff --git a/src/EntityFramework.Storage/src/Mappers/ScopeMappingExtensions.cs b/src/EntityFramework.Storage/src/Mappers/ScopeMappingExtensions.cs index b6a30d78e..c633dcf46 100644 --- a/src/EntityFramework.Storage/src/Mappers/ScopeMappingExtensions.cs +++ b/src/EntityFramework.Storage/src/Mappers/ScopeMappingExtensions.cs @@ -24,9 +24,10 @@ public Models.ApiScope ToModel() } /// - /// Mapper for to convert into an instance of + /// Mapper for to convert into an instance of . /// - /// mapped instance of + /// The type of model to map to. + /// mapped instance of . public T ToModel() where T : Models.ApiScope, new() { diff --git a/src/EntityFramework.Storage/src/Stores/ResourceStore.cs b/src/EntityFramework.Storage/src/Stores/ResourceStore.cs index a9e07e59f..085cfef07 100644 --- a/src/EntityFramework.Storage/src/Stores/ResourceStore.cs +++ b/src/EntityFramework.Storage/src/Stores/ResourceStore.cs @@ -79,7 +79,7 @@ where apiResourceNames.Contains(apiResource.Name) var result = (await apis.ToArrayAsync()) .Where(x => apiResourceNames.Contains(x.Name)) - .Select(x => x.ToModel()).ToArray(); + .Select(ToApiResourceModel).ToArray(); if (result.Any()) { @@ -107,7 +107,7 @@ public virtual async Task> FindApiResourcesByScopeNameA var query = from api in Context.ApiResources - where api.Scopes.Where(x => names.Contains(x.Scope)).Any() + where api.Scopes.Any(x => names.Contains(x.Scope)) select api; var apis = query @@ -119,13 +119,26 @@ where api.Scopes.Where(x => names.Contains(x.Scope)).Any() var results = (await apis.ToArrayAsync()) .Where(api => api.Scopes.Any(x => names.Contains(x.Scope))); - var models = results.Select(x => x.ToModel()).ToArray(); + var models = results.Select(ToApiResourceModel).ToArray(); Logger.LogDebug("Found {apis} API resources in database", models.Select(x => x.Name)); return models; } + /// + /// Maps the to the . + /// + /// The . + /// The or an object extending ApiScope. + /// + /// Makes it possible to return an extended model. + /// + protected virtual ApiResource ToApiResourceModel(Entities.ApiResource resource) + { + return resource.ToModel(); + } + /// /// Gets identity resources by scope name. /// @@ -225,8 +238,8 @@ public virtual async Task GetAllResourcesAsync() var result = new Resources( (await identity.ToArrayAsync()).Select(x => x.ToModel()), - (await apis.ToArrayAsync()).Select(x => x.ToModel()), - (await scopes.ToArrayAsync()).Select(x => x.ToModel()) + (await apis.ToArrayAsync()).Select(ToApiResourceModel), + (await scopes.ToArrayAsync()).Select(ToApiScopeModel) ); Logger.LogDebug("Found {scopes} as all scopes, and {apis} as API resources", diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedApiResource.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedApiResource.cs new file mode 100644 index 000000000..757baa413 --- /dev/null +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedApiResource.cs @@ -0,0 +1,17 @@ +using Open.IdentityServer.Models; +using System; + +namespace Open.IdentityServer.EntityFramework.IntegrationTests.Stores; + +internal class ExtendedApiResource : ApiResource +{ + public DateTime Created { get; set; } + public DateTime? Updated { get; set; } + public DateTime? LastAccessed { get; set; } + + public string? X + { + get => Properties.ContainsKey("x") ? Properties["x"] : null; + set => Properties["x"] = value; + } +} \ No newline at end of file diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedResourceStore.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedResourceStore.cs index 0c6595c30..ff3e0bb14 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedResourceStore.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedResourceStore.cs @@ -13,6 +13,17 @@ public ExtendedResourceStore(IConfigurationDbContext context, ITelemetryService { } + protected override Models.ApiResource ToApiResourceModel(Entities.ApiResource resource) + { + var model = resource.ToModel(); + + model.Created = resource.Created; + model.Updated = resource.Updated; + model.LastAccessed = resource.LastAccessed; + + return model; + } + protected override Models.ApiScope ToApiScopeModel(Entities.ApiScope scope) { var model = scope.ToModel(); diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ResourceStoreTests.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ResourceStoreTests.cs index 66d3bcd4b..fb17e9da5 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ResourceStoreTests.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ResourceStoreTests.cs @@ -3,20 +3,20 @@ // 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.Threading.Tasks; using AwesomeAssertions; +using Microsoft.EntityFrameworkCore; +using Moq; using Open.IdentityServer.EntityFramework.DbContexts; +using Open.IdentityServer.EntityFramework.Mappers; using Open.IdentityServer.EntityFramework.Options; using Open.IdentityServer.EntityFramework.Stores; using Open.IdentityServer.Models; -using Microsoft.EntityFrameworkCore; -using Moq; -using Open.IdentityServer.EntityFramework.Mappers; using Open.IdentityServer.Services; using Open.IdentityServer.Utility; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; using Xunit; namespace Open.IdentityServer.EntityFramework.IntegrationTests.Stores; @@ -107,6 +107,28 @@ public async Task FindApiResourcesByNameAsync_WhenResourceExists_ExpectResourceA Assert.NotEmpty(foundResource.Scopes); } + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FindApiResourcesByNameAsync_WhenResourceExists_ExpectExtendedResourceReturned(DbContextOptions options) + { + var resource = CreateApiResourceTestResource(); + resource.Properties.Add("x", "xx"); + + using (var context = new ConfigurationDbContext(options, StoreOptions)) + { + context.ApiResources.Add(resource.ToEntity()); + context.SaveChanges(); + } + + ExtendedApiResource foundResource; + using (var context = new ConfigurationDbContext(options, StoreOptions)) + { + var store = new ExtendedResourceStore(context, _telemetry, FakeLogger.Create()); + foundResource = (await store.FindApiResourcesByNameAsync([resource.Name])).SingleOrDefault() as ExtendedApiResource; + } + + Assert.NotNull(foundResource); + } + [Theory, MemberData(nameof(TestDatabaseProviders))] public async Task FindApiResourcesByNameAsync_WhenResourcesExist_ExpectOnlyResourcesRequestedReturned(DbContextOptions options) { @@ -166,6 +188,36 @@ public async Task FindApiResourcesByScopeNameAsync_WhenResourcesExist_ExpectReso Assert.NotNull(resources.Single(x => x.Name == testApiResource.Name)); } + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FindApiResourcesByScopeNameAsync_WhenResourcesExist_ExpectExtendedApiScopeReturned(DbContextOptions options) + { + var testApiResource = CreateApiResourceTestResource(); + var testApiScope = CreateApiScopeTestResource(); + testApiResource.Scopes.Add(testApiScope.Name); + testApiResource.Properties.Add("x", "xx"); + + using (var context = new ConfigurationDbContext(options, StoreOptions)) + { + context.ApiResources.Add(testApiResource.ToEntity()); + context.ApiScopes.Add(testApiScope.ToEntity()); + context.SaveChanges(); + } + + IEnumerable resources; + using (var context = new ConfigurationDbContext(options, StoreOptions)) + { + var store = new ExtendedResourceStore(context, _telemetry, FakeLogger.Create()); + resources = await store.FindApiResourcesByScopeNameAsync( + [ + testApiScope.Name + ]); + } + + Assert.NotNull(resources); + var extendedApiResource = resources.SingleOrDefault(x => x.Name == testApiResource.Name) as ExtendedApiResource; + Assert.NotNull(extendedApiResource); + } + [Theory, MemberData(nameof(TestDatabaseProviders))] public async Task FindApiResourcesByScopeNameAsync_WhenResourcesExist_ExpectOnlyResourcesRequestedReturned(DbContextOptions options) { diff --git a/src/EntityFramework.Storage/test/UnitTests/Mappers/ApiResourceMappersTests.cs b/src/EntityFramework.Storage/test/UnitTests/Mappers/ApiResourceMappersTests.cs index 35d37aa3f..77af4a553 100644 --- a/src/EntityFramework.Storage/test/UnitTests/Mappers/ApiResourceMappersTests.cs +++ b/src/EntityFramework.Storage/test/UnitTests/Mappers/ApiResourceMappersTests.cs @@ -2,11 +2,12 @@ // 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.Collections.Generic; -using System.Linq; using AwesomeAssertions; using Open.IdentityServer.EntityFramework.Entities; using Open.IdentityServer.EntityFramework.Mappers; +using System; +using System.Collections.Generic; +using System.Linq; using Xunit; using ApiResource = Open.IdentityServer.Models.ApiResource; using Secret = Open.IdentityServer.Models.Secret; @@ -140,6 +141,29 @@ public void EntitiesApiResourceToModel_MissingValues_ShouldUseDefaults() model.ApiSecrets.First().Type.Should().Be(def.ApiSecrets.First().Type); } + [Fact] + public void CanMapToExtendedScopeModel() + { + var entity = new Entities.ApiResource + { + Name = "foo", + DisplayName = "foo", + Description = "bar", + Created = DateTime.UtcNow.AddDays(-100), + Updated = DateTime.UtcNow.AddDays(-50), + LastAccessed = DateTime.UtcNow.AddDays(-10), + Properties = [new ApiResourceProperty { Key = "x", Value = "xx" }, new ApiResourceProperty { Key = "y", Value = "yy" }] + }; + + var model = entity.ToExtendedModel(); + + Assert.NotNull(model); + model.Created.Should().Be(entity.Created); + model.Updated.Should().Be(entity.Updated); + model.LastAccessed.Should().Be(entity.LastAccessed); + model.X.Should().Be(entity.Properties.Single(p => p.Key == "x").Value); + } + [Fact] public void ToEntity_maps_all_properties() { @@ -160,4 +184,38 @@ public void ToModel_maps_all_properties() new MappingVerifier() .Verify(entity => entity.ToModel()); } +} + +internal static class ExtendedApiResourceMappingExtensions +{ + extension(Entities.ApiResource apiResourceEntity) + { + /// + /// Mapper for to convert into an instance of + /// + /// mapped instance of + public ExtendedApiResource ToExtendedModel() + { + var model = apiResourceEntity.ToModel(); + model.Created = apiResourceEntity.Created; + model.Updated = apiResourceEntity.Updated; + model.LastAccessed = apiResourceEntity.LastAccessed; + + return model; + } + + } +} + +internal class ExtendedApiResource : ApiResource +{ + public DateTime Created { get; set; } + public DateTime? Updated { get; set; } + public DateTime? LastAccessed { get; set; } + + public string? X + { + get => Properties.ContainsKey("x") ? Properties["x"] : null; + set => Properties["x"] = value; + } } \ No newline at end of file From 23a78f5af775fafa2f45e78be92df5dd43d7c2f3 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sun, 6 Sep 2026 11:46:42 +0200 Subject: [PATCH 3/6] feat: Implemented full extensibility of IdentityResource model (not the entity). Includes tests. --- .../IdentityResourceMappingExtensions.cs | 13 ++++- .../src/Stores/ResourceStore.cs | 20 +++++-- .../Stores/ExtendedIdentityResource.cs | 16 ++++++ .../Stores/ExtendedResourceStore.cs | 10 ++++ .../Stores/ResourceStoreTests.cs | 27 +++++++++ .../Mappers/ApiResourceMappersTests.cs | 2 +- .../Mappers/IdentityResourcesMappersTests.cs | 56 +++++++++++++++++++ 7 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedIdentityResource.cs diff --git a/src/EntityFramework.Storage/src/Mappers/IdentityResourceMappingExtensions.cs b/src/EntityFramework.Storage/src/Mappers/IdentityResourceMappingExtensions.cs index d30c6180d..13853e072 100644 --- a/src/EntityFramework.Storage/src/Mappers/IdentityResourceMappingExtensions.cs +++ b/src/EntityFramework.Storage/src/Mappers/IdentityResourceMappingExtensions.cs @@ -20,7 +20,18 @@ public static class IdentityResourceMappingExtensions /// mapped instance of public Models.IdentityResource ToModel() { - return new Models.IdentityResource + return identityResourceEntity.ToModel(); + } + + /// + /// Mapper for to convert into an instance of . + /// + /// The type of model to map to. + /// mapped instance of . + public T ToModel() + where T : Models.IdentityResource, new() + { + return new T { Enabled = identityResourceEntity.Enabled, Name = identityResourceEntity.Name, diff --git a/src/EntityFramework.Storage/src/Stores/ResourceStore.cs b/src/EntityFramework.Storage/src/Stores/ResourceStore.cs index 085cfef07..d97fed335 100644 --- a/src/EntityFramework.Storage/src/Stores/ResourceStore.cs +++ b/src/EntityFramework.Storage/src/Stores/ResourceStore.cs @@ -20,8 +20,7 @@ namespace Open.IdentityServer.EntityFramework.Stores; /// /// Implementation of IResourceStore that uses EF. /// -/// - +/// public class ResourceStore : IResourceStore { /// @@ -166,7 +165,20 @@ where scopes.Contains(identityResource.Name) Logger.LogDebug("Found {scopes} identity scopes in database", results.Select(x => x.Name)); - return results.Select(x => x.ToModel()).ToArray(); + return results.Select(ToIdentityResourceModel).ToArray(); + } + + /// + /// Maps the to the . + /// + /// The . + /// The or an object extending IdentityResource. + /// + /// Makes it possible to return an extended model. + /// + protected virtual IdentityResource ToIdentityResourceModel(Entities.IdentityResource resource) + { + return resource.ToModel(); } /// @@ -237,7 +249,7 @@ public virtual async Task GetAllResourcesAsync() .AsNoTracking(); var result = new Resources( - (await identity.ToArrayAsync()).Select(x => x.ToModel()), + (await identity.ToArrayAsync()).Select(ToIdentityResourceModel), (await apis.ToArrayAsync()).Select(ToApiResourceModel), (await scopes.ToArrayAsync()).Select(ToApiScopeModel) ); diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedIdentityResource.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedIdentityResource.cs new file mode 100644 index 000000000..2533dfbfa --- /dev/null +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedIdentityResource.cs @@ -0,0 +1,16 @@ +using Open.IdentityServer.Models; +using System; + +namespace Open.IdentityServer.EntityFramework.IntegrationTests.Stores; + +internal class ExtendedIdentityResource : IdentityResource +{ + public DateTime Created { get; set; } + public DateTime? Updated { get; set; } + + public string? X + { + get => Properties.ContainsKey("x") ? Properties["x"] : null; + set => Properties["x"] = value; + } +} \ No newline at end of file diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedResourceStore.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedResourceStore.cs index ff3e0bb14..03c877521 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedResourceStore.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedResourceStore.cs @@ -24,6 +24,16 @@ protected override Models.ApiResource ToApiResourceModel(Entities.ApiResource re return model; } + protected override Models.IdentityResource ToIdentityResourceModel(Entities.IdentityResource resource) + { + var model = resource.ToModel(); + + model.Created = resource.Created; + model.Updated = resource.Updated; + + return model; + } + protected override Models.ApiScope ToApiScopeModel(Entities.ApiScope scope) { var model = scope.ToModel(); diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ResourceStoreTests.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ResourceStoreTests.cs index fb17e9da5..6365de7a4 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ResourceStoreTests.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ResourceStoreTests.cs @@ -279,6 +279,33 @@ public async Task FindIdentityResourcesByScopeNameAsync_WhenResourceExists_Expec Assert.NotEmpty(foundScope.UserClaims); } + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FindIdentityResourcesByScopeNameAsync_WhenResourceExists_ExpectExtendedResourceReturned(DbContextOptions options) + { + var resource = CreateIdentityTestResource(); + resource.Properties.Add("x", "xx"); + + using (var context = new ConfigurationDbContext(options, StoreOptions)) + { + context.IdentityResources.Add(resource.ToEntity()); + context.SaveChanges(); + } + + IList resources; + using (var context = new ConfigurationDbContext(options, StoreOptions)) + { + var store = new ExtendedResourceStore(context, _telemetry, FakeLogger.Create()); + resources = (await store.FindIdentityResourcesByScopeNameAsync(new List + { + resource.Name + })).ToList(); + } + + Assert.NotNull(resources); + var foundScope = resources.Single() as ExtendedIdentityResource; + Assert.NotNull(foundScope); + } + [Theory, MemberData(nameof(TestDatabaseProviders))] public async Task FindIdentityResourcesByScopeNameAsync_WhenResourcesExist_ExpectOnlyRequestedReturned(DbContextOptions options) { diff --git a/src/EntityFramework.Storage/test/UnitTests/Mappers/ApiResourceMappersTests.cs b/src/EntityFramework.Storage/test/UnitTests/Mappers/ApiResourceMappersTests.cs index 77af4a553..f282780a2 100644 --- a/src/EntityFramework.Storage/test/UnitTests/Mappers/ApiResourceMappersTests.cs +++ b/src/EntityFramework.Storage/test/UnitTests/Mappers/ApiResourceMappersTests.cs @@ -142,7 +142,7 @@ public void EntitiesApiResourceToModel_MissingValues_ShouldUseDefaults() } [Fact] - public void CanMapToExtendedScopeModel() + public void CanMapToExtendedApiResourceModel() { var entity = new Entities.ApiResource { diff --git a/src/EntityFramework.Storage/test/UnitTests/Mappers/IdentityResourcesMappersTests.cs b/src/EntityFramework.Storage/test/UnitTests/Mappers/IdentityResourcesMappersTests.cs index 795f3e488..b65de5db4 100644 --- a/src/EntityFramework.Storage/test/UnitTests/Mappers/IdentityResourcesMappersTests.cs +++ b/src/EntityFramework.Storage/test/UnitTests/Mappers/IdentityResourcesMappersTests.cs @@ -3,8 +3,11 @@ // Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. +using AwesomeAssertions; using Open.IdentityServer.EntityFramework.Mappers; using Open.IdentityServer.Models; +using System; +using System.Linq; using Xunit; namespace Open.IdentityServer.EntityFramework.UnitTests.Mappers; @@ -23,6 +26,27 @@ public void CanMapIdentityResources() Assert.NotNull(mappedEntity); } + [Fact] + public void CanMapToExtendedIdentityResourceModel() + { + var entity = new Entities.IdentityResource + { + Name = "foo", + DisplayName = "foo", + Description = "bar", + Created = DateTime.UtcNow.AddDays(-100), + Updated = DateTime.UtcNow.AddDays(-50), + Properties = [new Entities.IdentityResourceProperty { Key = "x", Value = "xx" }, new Entities.IdentityResourceProperty { Key = "y", Value = "yy" }] + }; + + var model = entity.ToExtendedModel(); + + Assert.NotNull(model); + model.Created.Should().Be(entity.Created); + model.Updated.Should().Be(entity.Updated); + model.X.Should().Be(entity.Properties.Single(p => p.Key == "x").Value); + } + [Fact] public void ToEntity_maps_all_properties() { @@ -42,4 +66,36 @@ public void ToModel_maps_all_properties() new MappingVerifier() .Verify(entity => entity.ToModel()); } +} + +internal static class ExtendedIdentityResourceMappingExtensions +{ + extension(Entities.IdentityResource identityResourceEntity) + { + /// + /// Mapper for to convert into an instance of + /// + /// mapped instance of + public ExtendedIdentityResource ToExtendedModel() + { + var model = identityResourceEntity.ToModel(); + model.Created = identityResourceEntity.Created; + model.Updated = identityResourceEntity.Updated; + + return model; + } + + } +} + +internal class ExtendedIdentityResource : IdentityResource +{ + public DateTime Created { get; set; } + public DateTime? Updated { get; set; } + + public string? X + { + get => Properties.ContainsKey("x") ? Properties["x"] : null; + set => Properties["x"] = value; + } } \ No newline at end of file From 3bfe32c0fecc98b28ce1e758b9e72ab45fd09db6 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sun, 6 Sep 2026 12:12:05 +0200 Subject: [PATCH 4/6] feat: Implemented full extensibility of Client model (not the entity). Includes tests. --- .../src/Mappers/ClientMappingExtensions.cs | 13 ++++- .../src/Stores/ClientStore.cs | 16 +++++- .../Stores/ClientStoreTests.cs | 28 +++++++++ .../Stores/ExtendedApiResource.cs | 5 +- .../Stores/ExtendedApiScope.cs | 5 +- .../IntegrationTests/Stores/ExtendedClient.cs | 20 +++++++ .../Stores/ExtendedClientStore.cs | 29 ++++++++++ .../Stores/ExtendedIdentityResource.cs | 5 +- .../Stores/ExtendedResourceStore.cs | 5 +- .../UnitTests/Mappers/ClientMappersTests.cs | 57 +++++++++++++++++++ 10 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedClient.cs create mode 100644 src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedClientStore.cs diff --git a/src/EntityFramework.Storage/src/Mappers/ClientMappingExtensions.cs b/src/EntityFramework.Storage/src/Mappers/ClientMappingExtensions.cs index ac9d823fc..f71384f8f 100644 --- a/src/EntityFramework.Storage/src/Mappers/ClientMappingExtensions.cs +++ b/src/EntityFramework.Storage/src/Mappers/ClientMappingExtensions.cs @@ -27,7 +27,18 @@ public static class ClientMappingExtensions /// mapped instance of public Models.Client ToModel() { - return new Models.Client + return clientEntity.ToModel(); + } + + /// + /// Mapper for to convert into an instance of . + /// + /// The type of model to map to. + /// mapped instance of . + public T ToModel() + where T : Models.Client, new() + { + return new T { Enabled = clientEntity.Enabled, ClientId = clientEntity.ClientId, diff --git a/src/EntityFramework.Storage/src/Stores/ClientStore.cs b/src/EntityFramework.Storage/src/Stores/ClientStore.cs index 56e278e32..f24314b99 100644 --- a/src/EntityFramework.Storage/src/Stores/ClientStore.cs +++ b/src/EntityFramework.Storage/src/Stores/ClientStore.cs @@ -80,10 +80,24 @@ public virtual async Task FindClientByIdAsync(string clientId) await baseQuery.Include(x => x.Properties).SelectMany(c => c.Properties).LoadAsync(); await baseQuery.Include(x => x.RedirectUris).SelectMany(c => c.RedirectUris).LoadAsync(); - var model = client.ToModel(); + var model = ToModel(client); Logger.LogDebug("{clientId} found in database: {clientIdFound}", clientId, model != null); return model; } + + /// + /// Maps the to the . + /// + /// The . + /// The or an object extending Client. + /// + /// Makes it possible to return an extended model. + /// + protected virtual Client ToModel(Entities.Client client) + { + return client.ToModel(); + } + } \ No newline at end of file diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ClientStoreTests.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ClientStoreTests.cs index 4a76a8ce1..09e2d3aaa 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ClientStoreTests.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ClientStoreTests.cs @@ -75,6 +75,34 @@ public async Task FindClientByIdAsync_WhenClientExists_ExpectClientRetured( client.Should().NotBeNull(); } + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FindClientByIdAsync_WhenClientExists_ExpectExtendedClientReturned( + DbContextOptions options) + { + var testClient = new Client + { + ClientId = "test_extended_client", + ClientName = "Test Extended Client", + Properties = { { "x", "xx" } } + }; + + await using (var context = new ConfigurationDbContext(options, StoreOptions)) + { + context.Clients.Add(testClient.ToEntity()); + await context.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + ExtendedClient client; + await using (var context = new ConfigurationDbContext(options, StoreOptions)) + { + var store = new ExtendedClientStore(context, _telemetry, FakeLogger.Create()); + client = await store.FindClientByIdAsync(testClient.ClientId) as ExtendedClient; + } + + client.Should().NotBeNull(); + client.X.Should().Be("xx"); + } + [Theory, MemberData(nameof(TestDatabaseProviders))] public async Task FindClientByIdAsync_WhenClientExistsWithCollections_ExpectClientReturnedCollections( DbContextOptions options) diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedApiResource.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedApiResource.cs index 757baa413..bbdf7ed76 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedApiResource.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedApiResource.cs @@ -1,4 +1,7 @@ -using Open.IdentityServer.Models; +// 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 Open.IdentityServer.Models; using System; namespace Open.IdentityServer.EntityFramework.IntegrationTests.Stores; diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedApiScope.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedApiScope.cs index 721853fb4..676cba10e 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedApiScope.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedApiScope.cs @@ -1,4 +1,7 @@ -using Open.IdentityServer.Models; +// 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 Open.IdentityServer.Models; using System; namespace Open.IdentityServer.EntityFramework.IntegrationTests.Stores; diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedClient.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedClient.cs new file mode 100644 index 000000000..a5c05e4a0 --- /dev/null +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedClient.cs @@ -0,0 +1,20 @@ +// 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 Open.IdentityServer.Models; +using System; + +namespace Open.IdentityServer.EntityFramework.IntegrationTests.Stores; + +internal class ExtendedClient : Client +{ + public DateTime Created { get; set; } + public DateTime? Updated { get; set; } + public DateTime? LastAccessed { get; internal set; } + + public string? X + { + get => Properties.ContainsKey("x") ? Properties["x"] : null; + set => Properties["x"] = value; + } +} \ No newline at end of file diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedClientStore.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedClientStore.cs new file mode 100644 index 000000000..055a566ff --- /dev/null +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedClientStore.cs @@ -0,0 +1,29 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +using Microsoft.Extensions.Logging; +using Open.IdentityServer.EntityFramework.Interfaces; +using Open.IdentityServer.EntityFramework.Mappers; +using Open.IdentityServer.EntityFramework.Stores; +using Open.IdentityServer.Services; + +namespace Open.IdentityServer.EntityFramework.IntegrationTests.Stores; + +internal class ExtendedClientStore : ClientStore +{ + public ExtendedClientStore(IConfigurationDbContext context, ITelemetryService telemetry, ILogger logger) + : base(context, telemetry, logger) + { + } + + protected override Models.Client ToModel(Entities.Client client) + { + var model = client.ToModel(); + + model.Created = client.Created; + model.Updated = client.Updated; + model.LastAccessed = client.LastAccessed; + + return model; + } +} diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedIdentityResource.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedIdentityResource.cs index 2533dfbfa..354f55c5b 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedIdentityResource.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedIdentityResource.cs @@ -1,4 +1,7 @@ -using Open.IdentityServer.Models; +// 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 Open.IdentityServer.Models; using System; namespace Open.IdentityServer.EntityFramework.IntegrationTests.Stores; diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedResourceStore.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedResourceStore.cs index 03c877521..f0cd95f63 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedResourceStore.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedResourceStore.cs @@ -1,4 +1,7 @@ -using Microsoft.Extensions.Logging; +// 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.Extensions.Logging; using Open.IdentityServer.EntityFramework.Interfaces; using Open.IdentityServer.EntityFramework.Mappers; using Open.IdentityServer.EntityFramework.Stores; diff --git a/src/EntityFramework.Storage/test/UnitTests/Mappers/ClientMappersTests.cs b/src/EntityFramework.Storage/test/UnitTests/Mappers/ClientMappersTests.cs index c419b6303..71ffed32c 100644 --- a/src/EntityFramework.Storage/test/UnitTests/Mappers/ClientMappersTests.cs +++ b/src/EntityFramework.Storage/test/UnitTests/Mappers/ClientMappersTests.cs @@ -98,6 +98,28 @@ public void missing_values_should_use_defaults() model.ClientSecrets.First().Type.Should().Be(def.ClientSecrets.First().Type); } + [Fact] + public void CanMapToExtendedClientModel() + { + var entity = new Entities.Client + { + ClientName = "foo", + Description = "bar", + Created = DateTime.UtcNow.AddDays(-100), + Updated = DateTime.UtcNow.AddDays(-50), + LastAccessed = DateTime.UtcNow.AddDays(-25), + Properties = [new Entities.ClientProperty { Key = "x", Value = "xx" }, new Entities.ClientProperty { Key = "y", Value = "yy" }] + }; + + var model = entity.ToExtendedModel(); + + Assert.NotNull(model); + model.Created.Should().Be(entity.Created); + model.Updated.Should().Be(entity.Updated); + model.LastAccessed.Should().Be(entity.LastAccessed); + model.X.Should().Be(entity.Properties.Single(p => p.Key == "x").Value); + } + [Fact] public void ToEntity_maps_all_properties() { @@ -139,4 +161,39 @@ public void ToModel_maps_all_properties() nameof(Client.RequirePushedAuthorization)) .Verify(entity => entity.ToModel()); } +} + +internal static class ExtendedClientMappingExtensions +{ + extension(Entities.Client clientEntity) + { + /// + /// Mapper for to convert into an instance of + + /// + /// mapped instance of + public ExtendedClient ToExtendedModel() + { + var model = clientEntity.ToModel(); + model.Created = clientEntity.Created; + model.Updated = clientEntity.Updated; + model.LastAccessed = clientEntity.LastAccessed; + + return model; + } + + } +} + +internal class ExtendedClient : Client +{ + public DateTime Created { get; set; } + public DateTime? Updated { get; set; } + public DateTime? LastAccessed { get; internal set; } + + public string? X + { + get => Properties.ContainsKey("x") ? Properties["x"] : null; + set => Properties["x"] = value; + } } \ No newline at end of file From 20bcb1445dc5b74c63bdb5c7014eedeed6597524 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Sun, 6 Sep 2026 12:23:23 +0200 Subject: [PATCH 5/6] fix: Minor consistency fix --- .../test/IntegrationTests/Stores/ExtendedClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedClient.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedClient.cs index a5c05e4a0..410604250 100644 --- a/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedClient.cs +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedClient.cs @@ -10,7 +10,7 @@ internal class ExtendedClient : Client { public DateTime Created { get; set; } public DateTime? Updated { get; set; } - public DateTime? LastAccessed { get; internal set; } + public DateTime? LastAccessed { get; set; } public string? X { From c144657390320057968937e3e1189384332d6ad6 Mon Sep 17 00:00:00 2001 From: Eric Quist Date: Mon, 7 Sep 2026 19:02:57 +0200 Subject: [PATCH 6/6] docs: Added documenation of how to customize the client (or Resource) models. --- docs/reference/ef.rst | 73 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/docs/reference/ef.rst b/docs/reference/ef.rst index 4fcb4357c..6469dec4c 100644 --- a/docs/reference/ef.rst +++ b/docs/reference/ef.rst @@ -39,6 +39,79 @@ To use the configuration store support, use the ``AddConfigurationStore`` extens To configure the configuration store, use the ``ConfigurationStoreOptions`` options object passed to the configuration callback. +Customize the Models Created for Clients and Resources +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +If you need to extend the Client and/or Resource models that are returned from the configuration stores, you can do so by +creating your own models that inherit from the default models and then override the ToModel methods in a custom store. Below +is an example of how to do this for the Client model. The same approach can be used for the Resources. + +Start by extending the Client model: + +.. code-block:: csharp + + public class MyClient : Client + { + private const string CibaNotificationEndpointKey = nameof(CibaNotificationEndpoint); + + public DateTime? LastAccessed { get; set; } + + public string? CibaNotificationEndpoint + { + get + { + if (!Properties.ContainsKey(Client.CibaNotificationEndpointKey)) + return null; + return Properties[Client.CibaNotificationEndpointKey]; + } + set + { + if (string.IsNullOrEmpty(value)) + Properties.Remove(CibaNotificationEndpointKey); + else + Properties[CibaNotificationEndpointKey] = value; + } + } + } + +Then create a custom store that overrides the ToModel method: + +.. code-block:: csharp + + public class MyClientStore : ClientStore + { + public MyClientStore(ConfigurationDbContext context, ITelemetryService telemetry, IOptions options) + : base(context, telemetry, options) + { + } + + protected override Models.Client ToModel(Entities.Client client) + { + var model = client.ToModel(); + + //Map additional properties here (not needed for properties that uses the Properties for storage and retrieval) + model.LastAccessed = client.LastAccessed; + + return model; + } + } + +Finally register your custom store in the DI container: + +.. code-block:: csharp + + services.AddIdentityServer() + .AddConfigurationStore(options => + { + options.ConfigureDbContext = builder => + builder.UseSqlServer(connectionString, + sql => sql.MigrationsAssembly(migrationsAssembly)); + }) + .AddClientStore(); + +By doing this you can now use your own models in customizations or extensions of the built-in support without needing to +create your own store from scratch (including all mapping of existing properties). + ConfigurationStoreOptions ^^^^^^^^^^^^^^^^^^^^^^^^^ This options class contains properties to control the configuration store and ``ConfigurationDbContext``.