diff --git a/docs/reference/ef.rst b/docs/reference/ef.rst index 4fcb4357..6469dec4 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``. diff --git a/src/EntityFramework.Storage/src/Mappers/ApiResourceMappingExtensions.cs b/src/EntityFramework.Storage/src/Mappers/ApiResourceMappingExtensions.cs index 187243b7..643c86e9 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/ClientMappingExtensions.cs b/src/EntityFramework.Storage/src/Mappers/ClientMappingExtensions.cs index ac9d823f..f71384f8 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/Mappers/IdentityResourceMappingExtensions.cs b/src/EntityFramework.Storage/src/Mappers/IdentityResourceMappingExtensions.cs index d30c6180..13853e07 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/Mappers/ScopeMappingExtensions.cs b/src/EntityFramework.Storage/src/Mappers/ScopeMappingExtensions.cs index f869874f..c633dcf4 100644 --- a/src/EntityFramework.Storage/src/Mappers/ScopeMappingExtensions.cs +++ b/src/EntityFramework.Storage/src/Mappers/ScopeMappingExtensions.cs @@ -20,7 +20,18 @@ 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 . + /// + /// The type of model to map to. + /// 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/ClientStore.cs b/src/EntityFramework.Storage/src/Stores/ClientStore.cs index 56e278e3..f24314b9 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/src/Stores/ResourceStore.cs b/src/EntityFramework.Storage/src/Stores/ResourceStore.cs index 55920a2d..d97fed33 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 { /// @@ -79,7 +78,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 +106,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 +118,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. /// @@ -153,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(); } /// @@ -183,7 +208,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(); } /// @@ -211,9 +249,9 @@ public virtual async Task GetAllResourcesAsync() .AsNoTracking(); 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 identity.ToArrayAsync()).Select(ToIdentityResourceModel), + (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/ClientStoreTests.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ClientStoreTests.cs index 4a76a8ce..09e2d3aa 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 new file mode 100644 index 00000000..bbdf7ed7 --- /dev/null +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedApiResource.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 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/ExtendedApiScope.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedApiScope.cs new file mode 100644 index 00000000..676cba10 --- /dev/null +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedApiScope.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 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/ExtendedClient.cs b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedClient.cs new file mode 100644 index 00000000..41060425 --- /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; 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 00000000..055a566f --- /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 new file mode 100644 index 00000000..354f55c5 --- /dev/null +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedIdentityResource.cs @@ -0,0 +1,19 @@ +// 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 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 new file mode 100644 index 00000000..f0cd95f6 --- /dev/null +++ b/src/EntityFramework.Storage/test/IntegrationTests/Stores/ExtendedResourceStore.cs @@ -0,0 +1,50 @@ +// Copyright (c) 2026, Rock Solid Knowledge Ltd +// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information. + +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.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.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(); + + 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 6eac4ae9..6365de7a 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) { @@ -227,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) { @@ -313,6 +392,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/ApiResourceMappersTests.cs b/src/EntityFramework.Storage/test/UnitTests/Mappers/ApiResourceMappersTests.cs index 35d37aa3..f282780a 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 CanMapToExtendedApiResourceModel() + { + 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 diff --git a/src/EntityFramework.Storage/test/UnitTests/Mappers/ClientMappersTests.cs b/src/EntityFramework.Storage/test/UnitTests/Mappers/ClientMappersTests.cs index c419b630..71ffed32 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 diff --git a/src/EntityFramework.Storage/test/UnitTests/Mappers/IdentityResourcesMappersTests.cs b/src/EntityFramework.Storage/test/UnitTests/Mappers/IdentityResourcesMappersTests.cs index 795f3e48..b65de5db 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 diff --git a/src/EntityFramework.Storage/test/UnitTests/Mappers/ScopeMappersTests.cs b/src/EntityFramework.Storage/test/UnitTests/Mappers/ScopeMappersTests.cs index b53af496..bf4e1dbc 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