From 0298194d71f844cf420cb7ee148d23b50bcf627b Mon Sep 17 00:00:00 2001 From: Damian Hickey <57436+damianh@users.noreply.github.com> Date: Wed, 21 Jan 2026 11:40:11 +0100 Subject: [PATCH 1/3] Tooling resorted the entries --- identity-server/identity-server.slnf | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/identity-server/identity-server.slnf b/identity-server/identity-server.slnf index 6a78fe8d9..8447a6926 100644 --- a/identity-server/identity-server.slnf +++ b/identity-server/identity-server.slnf @@ -40,19 +40,19 @@ "identity-server\\clients\\src\\MvcJarUriJwt\\MvcJarUriJwt.csproj", "identity-server\\clients\\src\\Web\\Web.csproj", "identity-server\\clients\\src\\WindowsConsoleSystemBrowser\\WindowsConsoleSystemBrowser.csproj", + "identity-server\\hosts\\Shared\\Host.Shared.csproj", + "identity-server\\hosts\\UI\\AspNetIdentity\\UI.AspNetIdentity.csproj", + "identity-server\\hosts\\UI\\EntityFramework\\UI.EntityFramework.csproj", + "identity-server\\hosts\\UI\\Main\\UI.Main.csproj", + "identity-server\\hosts\\net10\\AspNetIdentity10\\Host.AspNetIdentity10.csproj", + "identity-server\\hosts\\net10\\EntityFramework10\\Host.EntityFramework10.csproj", + "identity-server\\hosts\\net10\\Main10\\Host.Main10.csproj", "identity-server\\hosts\\net8\\AspNetIdentity8\\Host.AspNetIdentity8.csproj", "identity-server\\hosts\\net8\\EntityFramework8\\Host.EntityFramework8.csproj", "identity-server\\hosts\\net8\\Main8\\Host.Main8.csproj", "identity-server\\hosts\\net9\\AspNetIdentity9\\Host.AspNetIdentity9.csproj", "identity-server\\hosts\\net9\\EntityFramework9\\Host.EntityFramework9.csproj", "identity-server\\hosts\\net9\\Main9\\Host.Main9.csproj", - "identity-server\\hosts\\net10\\AspNetIdentity10\\Host.AspNetIdentity10.csproj", - "identity-server\\hosts\\net10\\EntityFramework10\\Host.EntityFramework10.csproj", - "identity-server\\hosts\\net10\\Main10\\Host.Main10.csproj", - "identity-server\\hosts\\Shared\\Host.Shared.csproj", - "identity-server\\hosts\\UI\\Main\\UI.Main.csproj", - "identity-server\\hosts\\UI\\EntityFramework\\UI.EntityFramework.csproj", - "identity-server\\hosts\\UI\\AspNetIdentity\\UI.AspNetIdentity.csproj", "identity-server\\migrations\\AspNetIdentityDb\\AspNetIdentityDb.csproj", "identity-server\\migrations\\IdentityServerDb\\IdentityServerDb.csproj", "identity-server\\src\\AspNetIdentity\\Duende.IdentityServer.AspNetIdentity.csproj", @@ -68,8 +68,8 @@ "identity-server\\templates\\src\\IdentityServerInMem\\IdentityServerInMem.csproj", "identity-server\\templates\\src\\IdentityServer\\IdentityServerTemplate.csproj", "identity-server\\templates\\src\\WebApp\\TemplateWebApp.csproj", - "identity-server\\test\\IdentityServer.IntegrationTests\\IdentityServer.IntegrationTests.csproj", "identity-server\\test\\IdentityServer.EndToEndTests\\IdentityServer.EndToEndTests.csproj", + "identity-server\\test\\IdentityServer.IntegrationTests\\IdentityServer.IntegrationTests.csproj", "identity-server\\test\\IdentityServer.UnitTests\\IdentityServer.UnitTests.csproj", "shared\\ShouldlyExtensions\\ShouldlyExtensions.csproj", "shared\\Xunit.Playwright\\Duende.Xunit.Playwright.csproj" From 288c217db0e4c2d8a65a44e5f9a1f8dab51c7b85 Mon Sep 17 00:00:00 2001 From: Damian Hickey <57436+damianh@users.noreply.github.com> Date: Wed, 21 Jan 2026 12:49:12 +0100 Subject: [PATCH 2/3] Add GetAllClientsAsync to IClientStore for client enumeration Add GetAllClientsAsync method to IClientStore interface with a default implementation that throws NotSupportedException. Implement in InMemoryClientStore and EF ClientStore using IAsyncEnumerable for memory-efficient streaming. Feature is NET10_0_OR_GREATER only to support conformance assessment scenarios. --- .../Stores/ClientStore.cs | 206 ++++---- .../Stores/InMemory/InMemoryClientStore.cs | 15 +- .../src/Storage/Stores/IClientStore.cs | 54 ++- .../Storage/Stores/ClientStoreTests.cs | 456 +++++++++++------- .../Stores/InMemoryClientStoreTests.cs | 120 +++-- 5 files changed, 528 insertions(+), 323 deletions(-) diff --git a/identity-server/src/EntityFramework.Storage/Stores/ClientStore.cs b/identity-server/src/EntityFramework.Storage/Stores/ClientStore.cs index e8a51c5dd..a0604eb3b 100644 --- a/identity-server/src/EntityFramework.Storage/Stores/ClientStore.cs +++ b/identity-server/src/EntityFramework.Storage/Stores/ClientStore.cs @@ -1,88 +1,118 @@ -// Copyright (c) Duende Software. All rights reserved. -// See LICENSE in the project root for license information. - - -using Duende.IdentityServer.EntityFramework.Interfaces; -using Duende.IdentityServer.EntityFramework.Mappers; -using Duende.IdentityServer.Services; -using Duende.IdentityServer.Stores; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; - -namespace Duende.IdentityServer.EntityFramework.Stores; - -/// -/// Implementation of IClientStore thats uses EF. -/// -/// -public class ClientStore : IClientStore -{ - /// - /// The DbContext. - /// - protected readonly IConfigurationDbContext Context; - - /// - /// The CancellationToken provider. - /// - protected readonly ICancellationTokenProvider CancellationTokenProvider; - - /// - /// The logger. - /// - protected readonly ILogger Logger; - - /// - /// Initializes a new instance of the class. - /// - /// The context. - /// The logger. - /// - /// context - public ClientStore(IConfigurationDbContext context, ILogger logger, ICancellationTokenProvider cancellationTokenProvider) - { - Context = context ?? throw new ArgumentNullException(nameof(context)); - Logger = logger; - CancellationTokenProvider = cancellationTokenProvider; - } - - /// - /// Finds a client by id - /// - /// The client id - /// - /// The client - /// - public virtual async Task FindClientByIdAsync(string clientId) - { - using var activity = Tracing.StoreActivitySource.StartActivity("ClientStore.FindClientById"); - activity?.SetTag(Tracing.Properties.ClientId, clientId); - - var query = Context.Clients - .Where(x => x.ClientId == clientId) - .Include(x => x.AllowedCorsOrigins) - .Include(x => x.AllowedGrantTypes) - .Include(x => x.AllowedScopes) - .Include(x => x.Claims) - .Include(x => x.ClientSecrets) - .Include(x => x.IdentityProviderRestrictions) - .Include(x => x.PostLogoutRedirectUris) - .Include(x => x.Properties) - .Include(x => x.RedirectUris) - .AsNoTracking() - .AsSplitQuery(); - - var client = (await query.ToArrayAsync(CancellationTokenProvider.CancellationToken)). - SingleOrDefault(x => x.ClientId == clientId); - if (client == null) - { - return null; - } - - var model = client.ToModel(); - - Logger.LogDebug("{clientId} found in database: {clientIdFound}", clientId, model != null); - - return model; - } -} +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + + +using Duende.IdentityServer.EntityFramework.Interfaces; +using Duende.IdentityServer.EntityFramework.Mappers; +using Duende.IdentityServer.Services; +using Duende.IdentityServer.Stores; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Duende.IdentityServer.EntityFramework.Stores; + +/// +/// Implementation of IClientStore thats uses EF. +/// +/// +public class ClientStore : IClientStore +{ + /// + /// The DbContext. + /// + protected readonly IConfigurationDbContext Context; + + /// + /// The CancellationToken provider. + /// + protected readonly ICancellationTokenProvider CancellationTokenProvider; + + /// + /// The logger. + /// + protected readonly ILogger Logger; + + /// + /// Initializes a new instance of the class. + /// + /// The context. + /// The logger. + /// + /// context + public ClientStore(IConfigurationDbContext context, ILogger logger, ICancellationTokenProvider cancellationTokenProvider) + { + Context = context ?? throw new ArgumentNullException(nameof(context)); + Logger = logger; + CancellationTokenProvider = cancellationTokenProvider; + } + + /// + /// Finds a client by id + /// + /// The client id + /// + /// The client + /// + public virtual async Task FindClientByIdAsync(string clientId) + { + using var activity = Tracing.StoreActivitySource.StartActivity("ClientStore.FindClientById"); + activity?.SetTag(Tracing.Properties.ClientId, clientId); + + var query = Context.Clients + .Where(x => x.ClientId == clientId) + .Include(x => x.AllowedCorsOrigins) + .Include(x => x.AllowedGrantTypes) + .Include(x => x.AllowedScopes) + .Include(x => x.Claims) + .Include(x => x.ClientSecrets) + .Include(x => x.IdentityProviderRestrictions) + .Include(x => x.PostLogoutRedirectUris) + .Include(x => x.Properties) + .Include(x => x.RedirectUris) + .AsNoTracking() + .AsSplitQuery(); + + var client = (await query.ToArrayAsync(CancellationTokenProvider.CancellationToken)). + SingleOrDefault(x => x.ClientId == clientId); + if (client == null) + { + return null; + } + + var model = client.ToModel(); + + Logger.LogDebug("{clientId} found in database: {clientIdFound}", clientId, model != null); + + return model; + } + +#if NET10_0_OR_GREATER + /// + public virtual async IAsyncEnumerable GetAllClientsAsync() + { + using var activity = Tracing.StoreActivitySource.StartActivity("ClientStore.GetAllClients"); + + var query = Context.Clients + .Include(x => x.AllowedCorsOrigins) + .Include(x => x.AllowedGrantTypes) + .Include(x => x.AllowedScopes) + .Include(x => x.Claims) + .Include(x => x.ClientSecrets) + .Include(x => x.IdentityProviderRestrictions) + .Include(x => x.PostLogoutRedirectUris) + .Include(x => x.Properties) + .Include(x => x.RedirectUris) + .AsNoTracking() + .AsSplitQuery(); + + var clientCount = 0; + await foreach (var client in query.AsAsyncEnumerable().WithCancellation(CancellationTokenProvider.CancellationToken)) + { + clientCount++; + yield return client.ToModel(); + } + + Logger.LogDebug("Retrieved {clientCount} clients for enumeration", clientCount); + } +#endif +} diff --git a/identity-server/src/IdentityServer/Stores/InMemory/InMemoryClientStore.cs b/identity-server/src/IdentityServer/Stores/InMemory/InMemoryClientStore.cs index 647c3f4f8..588af4048 100644 --- a/identity-server/src/IdentityServer/Stores/InMemory/InMemoryClientStore.cs +++ b/identity-server/src/IdentityServer/Stores/InMemory/InMemoryClientStore.cs @@ -1,7 +1,6 @@ // Copyright (c) Duende Software. All rights reserved. // See LICENSE in the project root for license information. - using Duende.IdentityServer.Extensions; using Duende.IdentityServer.Models; @@ -46,4 +45,18 @@ public class InMemoryClientStore : IClientStore return Task.FromResult(query.SingleOrDefault()); } + +#if NET10_0_OR_GREATER + /// + public async IAsyncEnumerable GetAllClientsAsync() + { + using var activity = Tracing.StoreActivitySource.StartActivity("InMemoryClientStore.GetAllClients"); + + foreach (var client in _clients) + { + yield return client; + } + } +#endif } + diff --git a/identity-server/src/Storage/Stores/IClientStore.cs b/identity-server/src/Storage/Stores/IClientStore.cs index 373998010..681d02834 100644 --- a/identity-server/src/Storage/Stores/IClientStore.cs +++ b/identity-server/src/Storage/Stores/IClientStore.cs @@ -1,22 +1,32 @@ -// Copyright (c) Duende Software. All rights reserved. -// See LICENSE in the project root for license information. - - -#nullable enable - -using Duende.IdentityServer.Models; - -namespace Duende.IdentityServer.Stores; - -/// -/// Retrieval of client configuration -/// -public interface IClientStore -{ - /// - /// Finds a client by id - /// - /// The client id - /// The client - Task FindClientByIdAsync(string clientId); -} +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + + +#nullable enable + +using Duende.IdentityServer.Models; + +namespace Duende.IdentityServer.Stores; + +/// +/// Retrieval of client configuration +/// +public interface IClientStore +{ + /// + /// Finds a client by id + /// + /// The client id + /// The client + Task FindClientByIdAsync(string clientId); + +#if NET10_0_OR_GREATER + /// + /// Returns all clients for enumeration purposes (e.g., conformance assessment). + /// This method has a default implementation that throws . + /// + /// An async enumerable of all clients. + IAsyncEnumerable GetAllClientsAsync() + => throw new NotSupportedException("Client enumeration is not supported by this store implementation."); +#endif +} diff --git a/identity-server/test/IdentityServer.IntegrationTests/EntityFramework/Storage/Stores/ClientStoreTests.cs b/identity-server/test/IdentityServer.IntegrationTests/EntityFramework/Storage/Stores/ClientStoreTests.cs index 1d0a039e7..abc5c1e10 100644 --- a/identity-server/test/IdentityServer.IntegrationTests/EntityFramework/Storage/Stores/ClientStoreTests.cs +++ b/identity-server/test/IdentityServer.IntegrationTests/EntityFramework/Storage/Stores/ClientStoreTests.cs @@ -1,174 +1,282 @@ -// Copyright (c) Duende Software. All rights reserved. -// See LICENSE in the project root for license information. - - -using Duende.IdentityServer.EntityFramework.DbContexts; -using Duende.IdentityServer.EntityFramework.Mappers; -using Duende.IdentityServer.EntityFramework.Options; -using Duende.IdentityServer.EntityFramework.Stores; -using Duende.IdentityServer.Models; -using Duende.IdentityServer.Services; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging.Abstractions; -using Xunit.Sdk; - -namespace Duende.IdentityServer.IntegrationTests.EntityFramework.Storage.Stores; - -public class ClientStoreTests : IntegrationTest -{ - public ClientStoreTests(DatabaseProviderFixture fixture) : base(fixture) - { - foreach (var options in TestDatabaseProviders) - { - using var context = new ConfigurationDbContext(options); - context.Database.EnsureCreated(); - } - } - - [Theory, MemberData(nameof(TestDatabaseProviders))] - public async Task FindClientByIdAsync_WhenClientDoesNotExist_ExpectNull(DbContextOptions options) - { - await using var context = new ConfigurationDbContext(options); - var store = new ClientStore(context, new NullLogger(), new NoneCancellationTokenProvider()); - var client = await store.FindClientByIdAsync(Guid.NewGuid().ToString()); - client.ShouldBeNull(); - } - - [Theory, MemberData(nameof(TestDatabaseProviders))] - public async Task FindClientByIdAsync_WhenClientExists_ExpectClientReturned(DbContextOptions options) - { - var testClient = new Client - { - ClientId = "test_client", - ClientName = "Test Client" - }; - - await using (var context = new ConfigurationDbContext(options)) - { - context.Clients.Add(testClient.ToEntity()); - await context.SaveChangesAsync(); - } - - Client client; - await using (var context = new ConfigurationDbContext(options)) - { - var store = new ClientStore(context, new NullLogger(), new NoneCancellationTokenProvider()); - client = await store.FindClientByIdAsync(testClient.ClientId); - } - - client.ShouldNotBeNull(); - } - - [Theory, MemberData(nameof(TestDatabaseProviders))] - public async Task FindClientByIdAsync_WhenClientExistsWithCollections_ExpectClientReturnedCollections(DbContextOptions options) - { - var testClient = new Client - { - ClientId = "properties_test_client", - ClientName = "Properties Test Client", - AllowedCorsOrigins = { "https://localhost" }, - AllowedGrantTypes = GrantTypes.HybridAndClientCredentials, - AllowedScopes = { "openid", "profile", "api1" }, - Claims = { new ClientClaim("test", "value") }, - ClientSecrets = { new Secret("secret".Sha256()) }, - IdentityProviderRestrictions = { "AD" }, - PostLogoutRedirectUris = { "https://locahost/signout-callback" }, - Properties = { { "foo1", "bar1" }, { "foo2", "bar2" }, }, - RedirectUris = { "https://locahost/signin" } - }; - - await using (var context = new ConfigurationDbContext(options)) - { - context.Clients.Add(testClient.ToEntity()); - await context.SaveChangesAsync(); - } - - Client client; - await using (var context = new ConfigurationDbContext(options)) - { - var store = new ClientStore(context, new NullLogger(), new NoneCancellationTokenProvider()); - client = await store.FindClientByIdAsync(testClient.ClientId); - } - - client.ShouldSatisfyAllConditions(c => - { - c.ClientId.ShouldBe(testClient.ClientId); - c.ClientName.ShouldBe(testClient.ClientName); - c.AllowedCorsOrigins.ShouldBe(testClient.AllowedCorsOrigins); - c.AllowedGrantTypes.ShouldBe(testClient.AllowedGrantTypes, true); - c.AllowedScopes.ShouldBe(testClient.AllowedScopes, true); - c.Claims.ShouldBe(testClient.Claims); - c.ClientSecrets.ShouldBe(testClient.ClientSecrets, true); - c.IdentityProviderRestrictions.ShouldBe(testClient.IdentityProviderRestrictions); - c.PostLogoutRedirectUris.ShouldBe(testClient.PostLogoutRedirectUris); - c.Properties.ShouldBe(testClient.Properties); - c.RedirectUris.ShouldBe(testClient.RedirectUris); - }); - } - - [Theory, MemberData(nameof(TestDatabaseProviders))] - public async Task FindClientByIdAsync_WhenClientsExistWithManyCollections_ExpectClientReturnedInUnderFiveSeconds(DbContextOptions options) - { - var testClient = new Client - { - ClientId = "test_client_with_uris", - ClientName = "Test client with URIs", - AllowedScopes = { "openid", "profile", "api1" }, - AllowedGrantTypes = GrantTypes.CodeAndClientCredentials - }; - - for (var i = 0; i < 50; i++) - { - testClient.RedirectUris.Add($"https://localhost/{i}"); - testClient.PostLogoutRedirectUris.Add($"https://localhost/{i}"); - testClient.AllowedCorsOrigins.Add($"https://localhost:{i}"); - } - - await using (var context = new ConfigurationDbContext(options)) - { - context.Clients.Add(testClient.ToEntity()); - - for (var i = 0; i < 50; i++) - { - context.Clients.Add(new Client - { - ClientId = testClient.ClientId + i, - ClientName = testClient.ClientName, - AllowedScopes = testClient.AllowedScopes, - AllowedGrantTypes = testClient.AllowedGrantTypes, - RedirectUris = testClient.RedirectUris, - PostLogoutRedirectUris = testClient.PostLogoutRedirectUris, - AllowedCorsOrigins = testClient.AllowedCorsOrigins, - }.ToEntity()); - } - - context.SaveChanges(); - } - - await using (var context = new ConfigurationDbContext(options)) - { - var store = new ClientStore(context, new NullLogger(), new NoneCancellationTokenProvider()); - - const int timeout = 5000; - var task = Task.Run(() => store.FindClientByIdAsync(testClient.ClientId)); - - if (await Task.WhenAny(task, Task.Delay(timeout)) == task) - { -#pragma warning disable xUnit1031 // Do not use blocking task operations in test method, suppressed because the task must have completed to enter this block - var client = task.Result; -#pragma warning restore xUnit1031 // Do not use blocking task operations in test method - client.ShouldSatisfyAllConditions(c => - { - c.ClientId.ShouldBe(testClient.ClientId); - c.ClientName.ShouldBe(testClient.ClientName); - c.AllowedScopes.ShouldBe(testClient.AllowedScopes, true); - c.AllowedGrantTypes.ShouldBe(testClient.AllowedGrantTypes); - }); - } - else - { - throw new TestTimeoutException(timeout); - } - } - } -} +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + + +using Duende.IdentityServer.EntityFramework.DbContexts; +using Duende.IdentityServer.EntityFramework.Mappers; +using Duende.IdentityServer.EntityFramework.Options; +using Duende.IdentityServer.EntityFramework.Stores; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit.Sdk; + +namespace Duende.IdentityServer.IntegrationTests.EntityFramework.Storage.Stores; + +public class ClientStoreTests : IntegrationTest +{ + public ClientStoreTests(DatabaseProviderFixture fixture) : base(fixture) + { + foreach (var options in TestDatabaseProviders) + { + using var context = new ConfigurationDbContext(options); + context.Database.EnsureCreated(); + } + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FindClientByIdAsync_WhenClientDoesNotExist_ExpectNull(DbContextOptions options) + { + await using var context = new ConfigurationDbContext(options); + var store = new ClientStore(context, new NullLogger(), new NoneCancellationTokenProvider()); + var client = await store.FindClientByIdAsync(Guid.NewGuid().ToString()); + client.ShouldBeNull(); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FindClientByIdAsync_WhenClientExists_ExpectClientReturned(DbContextOptions options) + { + var testClient = new Client + { + ClientId = "test_client", + ClientName = "Test Client" + }; + + await using (var context = new ConfigurationDbContext(options)) + { + context.Clients.Add(testClient.ToEntity()); + await context.SaveChangesAsync(); + } + + Client client; + await using (var context = new ConfigurationDbContext(options)) + { + var store = new ClientStore(context, new NullLogger(), new NoneCancellationTokenProvider()); + client = await store.FindClientByIdAsync(testClient.ClientId); + } + + client.ShouldNotBeNull(); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FindClientByIdAsync_WhenClientExistsWithCollections_ExpectClientReturnedCollections(DbContextOptions options) + { + var testClient = new Client + { + ClientId = "properties_test_client", + ClientName = "Properties Test Client", + AllowedCorsOrigins = { "https://localhost" }, + AllowedGrantTypes = GrantTypes.HybridAndClientCredentials, + AllowedScopes = { "openid", "profile", "api1" }, + Claims = { new ClientClaim("test", "value") }, + ClientSecrets = { new Secret("secret".Sha256()) }, + IdentityProviderRestrictions = { "AD" }, + PostLogoutRedirectUris = { "https://locahost/signout-callback" }, + Properties = { { "foo1", "bar1" }, { "foo2", "bar2" }, }, + RedirectUris = { "https://locahost/signin" } + }; + + await using (var context = new ConfigurationDbContext(options)) + { + context.Clients.Add(testClient.ToEntity()); + await context.SaveChangesAsync(); + } + + Client client; + await using (var context = new ConfigurationDbContext(options)) + { + var store = new ClientStore(context, new NullLogger(), new NoneCancellationTokenProvider()); + client = await store.FindClientByIdAsync(testClient.ClientId); + } + + client.ShouldSatisfyAllConditions(c => + { + c.ClientId.ShouldBe(testClient.ClientId); + c.ClientName.ShouldBe(testClient.ClientName); + c.AllowedCorsOrigins.ShouldBe(testClient.AllowedCorsOrigins); + c.AllowedGrantTypes.ShouldBe(testClient.AllowedGrantTypes, true); + c.AllowedScopes.ShouldBe(testClient.AllowedScopes, true); + c.Claims.ShouldBe(testClient.Claims); + c.ClientSecrets.ShouldBe(testClient.ClientSecrets, true); + c.IdentityProviderRestrictions.ShouldBe(testClient.IdentityProviderRestrictions); + c.PostLogoutRedirectUris.ShouldBe(testClient.PostLogoutRedirectUris); + c.Properties.ShouldBe(testClient.Properties); + c.RedirectUris.ShouldBe(testClient.RedirectUris); + }); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task FindClientByIdAsync_WhenClientsExistWithManyCollections_ExpectClientReturnedInUnderFiveSeconds(DbContextOptions options) + { + var testClient = new Client + { + ClientId = "test_client_with_uris", + ClientName = "Test client with URIs", + AllowedScopes = { "openid", "profile", "api1" }, + AllowedGrantTypes = GrantTypes.CodeAndClientCredentials + }; + + for (var i = 0; i < 50; i++) + { + testClient.RedirectUris.Add($"https://localhost/{i}"); + testClient.PostLogoutRedirectUris.Add($"https://localhost/{i}"); + testClient.AllowedCorsOrigins.Add($"https://localhost:{i}"); + } + + await using (var context = new ConfigurationDbContext(options)) + { + context.Clients.Add(testClient.ToEntity()); + + for (var i = 0; i < 50; i++) + { + context.Clients.Add(new Client + { + ClientId = testClient.ClientId + i, + ClientName = testClient.ClientName, + AllowedScopes = testClient.AllowedScopes, + AllowedGrantTypes = testClient.AllowedGrantTypes, + RedirectUris = testClient.RedirectUris, + PostLogoutRedirectUris = testClient.PostLogoutRedirectUris, + AllowedCorsOrigins = testClient.AllowedCorsOrigins, + }.ToEntity()); + } + + context.SaveChanges(); + } + + await using (var context = new ConfigurationDbContext(options)) + { + var store = new ClientStore(context, new NullLogger(), new NoneCancellationTokenProvider()); + + const int timeout = 5000; + var task = Task.Run(() => store.FindClientByIdAsync(testClient.ClientId)); + + if (await Task.WhenAny(task, Task.Delay(timeout)) == task) + { +#pragma warning disable xUnit1031 // Do not use blocking task operations in test method, suppressed because the task must have completed to enter this block + var client = task.Result; +#pragma warning restore xUnit1031 // Do not use blocking task operations in test method + client.ShouldSatisfyAllConditions(c => + { + c.ClientId.ShouldBe(testClient.ClientId); + c.ClientName.ShouldBe(testClient.ClientName); + c.AllowedScopes.ShouldBe(testClient.AllowedScopes, true); + c.AllowedGrantTypes.ShouldBe(testClient.AllowedGrantTypes); + }); + } + else + { + throw new TestTimeoutException(timeout); + } + } + } + +#if NET10_0_OR_GREATER + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task GetAllClientsAsync_WhenNoClientsExist_ExpectEmptyCollection(DbContextOptions options) + { + await using var context = new ConfigurationDbContext(options); + var store = new ClientStore(context, new NullLogger(), new NoneCancellationTokenProvider()); + + var clients = new List(); + await foreach (var client in store.GetAllClientsAsync()) + { + clients.Add(client); + } + + clients.ShouldNotBeNull(); + clients.ShouldBeEmpty(); + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task GetAllClientsAsync_WhenClientsExist_ExpectAllClientsReturned(DbContextOptions options) + { + var testClients = new List + { + new Client { ClientId = "enum_client1", ClientName = "Enum Client 1" }, + new Client { ClientId = "enum_client2", ClientName = "Enum Client 2" }, + new Client { ClientId = "enum_client3", ClientName = "Enum Client 3" } + }; + + await using (var context = new ConfigurationDbContext(options)) + { + foreach (var client in testClients) + { + context.Clients.Add(client.ToEntity()); + } + await context.SaveChangesAsync(); + } + + await using (var context = new ConfigurationDbContext(options)) + { + var store = new ClientStore(context, new NullLogger(), new NoneCancellationTokenProvider()); + + var clients = new List(); + await foreach (var client in store.GetAllClientsAsync()) + { + clients.Add(client); + } + + clients.ShouldNotBeNull(); + clients.Count.ShouldBeGreaterThanOrEqualTo(3); + clients.ShouldContain(c => c.ClientId == "enum_client1"); + clients.ShouldContain(c => c.ClientId == "enum_client2"); + clients.ShouldContain(c => c.ClientId == "enum_client3"); + } + } + + [Theory, MemberData(nameof(TestDatabaseProviders))] + public async Task GetAllClientsAsync_WhenClientsExistWithCollections_ExpectCollectionsIncluded(DbContextOptions options) + { + var testClient = new Client + { + ClientId = "enum_collections_client", + ClientName = "Enum Collections Client", + AllowedCorsOrigins = { "https://localhost" }, + AllowedGrantTypes = GrantTypes.HybridAndClientCredentials, + AllowedScopes = { "openid", "profile", "api1" }, + Claims = { new ClientClaim("test", "value") }, + ClientSecrets = { new Secret("secret".Sha256()) }, + IdentityProviderRestrictions = { "AD" }, + PostLogoutRedirectUris = { "https://localhost/signout-callback" }, + Properties = { { "foo1", "bar1" } }, + RedirectUris = { "https://localhost/signin" } + }; + + await using (var context = new ConfigurationDbContext(options)) + { + context.Clients.Add(testClient.ToEntity()); + await context.SaveChangesAsync(); + } + + await using (var context = new ConfigurationDbContext(options)) + { + var store = new ClientStore(context, new NullLogger(), new NoneCancellationTokenProvider()); + + var clients = new List(); + await foreach (var c in store.GetAllClientsAsync()) + { + clients.Add(c); + } + var client = clients.FirstOrDefault(c => c.ClientId == testClient.ClientId); + + client.ShouldSatisfyAllConditions(c => + { + c.ShouldNotBeNull(); + c.ClientId.ShouldBe(testClient.ClientId); + c.ClientName.ShouldBe(testClient.ClientName); + c.AllowedCorsOrigins.ShouldBe(testClient.AllowedCorsOrigins); + c.AllowedGrantTypes.ShouldBe(testClient.AllowedGrantTypes, true); + c.AllowedScopes.ShouldBe(testClient.AllowedScopes, true); + c.Claims.ShouldBe(testClient.Claims); + c.ClientSecrets.ShouldBe(testClient.ClientSecrets, true); + c.IdentityProviderRestrictions.ShouldBe(testClient.IdentityProviderRestrictions); + c.PostLogoutRedirectUris.ShouldBe(testClient.PostLogoutRedirectUris); + c.Properties.ShouldBe(testClient.Properties); + c.RedirectUris.ShouldBe(testClient.RedirectUris); + }); + } + } +#endif +} diff --git a/identity-server/test/IdentityServer.UnitTests/Stores/InMemoryClientStoreTests.cs b/identity-server/test/IdentityServer.UnitTests/Stores/InMemoryClientStoreTests.cs index 9ab313f41..559f4085a 100644 --- a/identity-server/test/IdentityServer.UnitTests/Stores/InMemoryClientStoreTests.cs +++ b/identity-server/test/IdentityServer.UnitTests/Stores/InMemoryClientStoreTests.cs @@ -1,38 +1,82 @@ -// Copyright (c) Duende Software. All rights reserved. -// See LICENSE in the project root for license information. - - -using Duende.IdentityServer.Models; -using Duende.IdentityServer.Stores; - -namespace UnitTests.Stores; - -public class InMemoryClientStoreTests -{ - [Fact] - public void InMemoryClient_should_throw_if_contain_duplicate_client_ids() - { - var clients = new List - { - new Client { ClientId = "1"}, - new Client { ClientId = "1"}, - new Client { ClientId = "3"} - }; - - Action act = () => new InMemoryClientStore(clients); - act.ShouldThrow(); - } - - [Fact] - public void InMemoryClient_should_not_throw_if_does_not_contain_duplicate_client_ids() - { - var clients = new List - { - new Client { ClientId = "1"}, - new Client { ClientId = "2"}, - new Client { ClientId = "3"} - }; - - new InMemoryClientStore(clients); - } -} +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + + +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Stores; + +namespace UnitTests.Stores; + +public class InMemoryClientStoreTests +{ + [Fact] + public void InMemoryClient_should_throw_if_contain_duplicate_client_ids() + { + var clients = new List + { + new Client { ClientId = "1"}, + new Client { ClientId = "1"}, + new Client { ClientId = "3"} + }; + + Action act = () => new InMemoryClientStore(clients); + act.ShouldThrow(); + } + + [Fact] + public void InMemoryClient_should_not_throw_if_does_not_contain_duplicate_client_ids() + { + var clients = new List + { + new Client { ClientId = "1"}, + new Client { ClientId = "2"}, + new Client { ClientId = "3"} + }; + + new InMemoryClientStore(clients); + } + +#if NET10_0_OR_GREATER + [Fact] + public async Task GetAllClientsAsync_should_return_all_clients() + { + var clients = new List + { + new Client { ClientId = "client1", ClientName = "Client One" }, + new Client { ClientId = "client2", ClientName = "Client Two" }, + new Client { ClientId = "client3", ClientName = "Client Three" } + }; + + var store = new InMemoryClientStore(clients); + + var result = new List(); + await foreach (var client in store.GetAllClientsAsync()) + { + result.Add(client); + } + + result.ShouldNotBeNull(); + result.Count.ShouldBe(3); + result.ShouldContain(c => c.ClientId == "client1"); + result.ShouldContain(c => c.ClientId == "client2"); + result.ShouldContain(c => c.ClientId == "client3"); + } + + [Fact] + public async Task GetAllClientsAsync_should_return_empty_when_no_clients() + { + var clients = new List(); + + var store = new InMemoryClientStore(clients); + + var result = new List(); + await foreach (var client in store.GetAllClientsAsync()) + { + result.Add(client); + } + + result.ShouldNotBeNull(); + result.ShouldBeEmpty(); + } +#endif +} From b4a95cb55677e18816f580754decbc2557a49268 Mon Sep 17 00:00:00 2001 From: Damian Hickey <57436+damianh@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:42:39 +0100 Subject: [PATCH 3/3] Add GetAllClientsAsync to ValidatingClientStore with tests Implement IClientStore.GetAllClientsAsync in ValidatingClientStore for NET10, delegating to the inner store and validating each client before yielding. Invalid clients are filtered out with events raised. --- .../Stores/ValidatingClientStore.cs | 27 +++ .../Stores/ValidatingClientStoreTests.cs | 211 ++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 identity-server/test/IdentityServer.UnitTests/Stores/ValidatingClientStoreTests.cs diff --git a/identity-server/src/IdentityServer/Stores/ValidatingClientStore.cs b/identity-server/src/IdentityServer/Stores/ValidatingClientStore.cs index 64b5d9cbb..14465a8cc 100644 --- a/identity-server/src/IdentityServer/Stores/ValidatingClientStore.cs +++ b/identity-server/src/IdentityServer/Stores/ValidatingClientStore.cs @@ -77,4 +77,31 @@ public class ValidatingClientStore : IClientStore return null; } + +#if NET10_0_OR_GREATER + /// + public async IAsyncEnumerable GetAllClientsAsync() + { + using var activity = Tracing.StoreActivitySource.StartActivity("ValidatingClientStore.GetAllClients"); + await foreach (var client in _inner.GetAllClientsAsync()) + { + _logger.LogTrace("Calling into client configuration validator: {validatorType}", _validatorType); + var context = new ClientConfigurationValidationContext(client); + await _validator.ValidateAsync(context); + if (context.IsValid) + { + _logger.LogDebug("client configuration validation for client {clientId} succeeded.", client.ClientId); + Telemetry.Metrics.ClientValidation(client.ClientId); + yield return client; + } + else + { + _logger.LogError("Invalid client configuration for client {clientId}: {errorMessage}", client.ClientId, context.ErrorMessage); + Telemetry.Metrics.ClientValidationFailure(client.ClientId, context.ErrorMessage); + await _events.RaiseAsync(new InvalidClientConfigurationEvent(client, context.ErrorMessage)); + // Skip invalid clients - do not yield + } + } + } +#endif } diff --git a/identity-server/test/IdentityServer.UnitTests/Stores/ValidatingClientStoreTests.cs b/identity-server/test/IdentityServer.UnitTests/Stores/ValidatingClientStoreTests.cs new file mode 100644 index 000000000..5c730b7ae --- /dev/null +++ b/identity-server/test/IdentityServer.UnitTests/Stores/ValidatingClientStoreTests.cs @@ -0,0 +1,211 @@ +// Copyright (c) Duende Software. All rights reserved. +// See LICENSE in the project root for license information. + +#nullable enable + +using Duende.IdentityServer.Events; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Services; +using Duende.IdentityServer.Stores; +using Duende.IdentityServer.Validation; +using Microsoft.Extensions.Logging.Abstractions; +using UnitTests.Common; + +namespace UnitTests.Stores; + +public class ValidatingClientStoreTests +{ + private readonly TestEventService _events = new(); + private readonly NullLogger> _logger = new(); + +#if NET10_0_OR_GREATER + [Fact] + public async Task GetAllClientsAsync_WhenAllClientsAreValid_ShouldReturnAllClients() + { + var clients = new List + { + new() { ClientId = "client1" }, + new() { ClientId = "client2" }, + new() { ClientId = "client3" } + }; + var innerStore = StubClientStore.WithClients(clients); + var validator = new StubClientConfigurationValidator(isValid: true); + var store = new ValidatingClientStore(innerStore, validator, _events, _logger); + + var result = new List(); + await foreach (var client in store.GetAllClientsAsync()) + { + result.Add(client); + } + + result.Count.ShouldBe(3); + result.ShouldContain(c => c.ClientId == "client1"); + result.ShouldContain(c => c.ClientId == "client2"); + result.ShouldContain(c => c.ClientId == "client3"); + } + + [Fact] + public async Task GetAllClientsAsync_WhenSomeClientsAreInvalid_ShouldReturnOnlyValidClients() + { + var clients = new List + { + new() { ClientId = "valid1" }, + new() { ClientId = "invalid1" }, + new() { ClientId = "valid2" } + }; + var innerStore = StubClientStore.WithClients(clients); + // Validator that marks clients with "invalid" in the name as invalid + var validator = new StubClientConfigurationValidator( + validationFunc: client => !client.ClientId.Contains("invalid"), + errorMessage: "Client is invalid" + ); + var store = new ValidatingClientStore(innerStore, validator, _events, _logger); + + var result = new List(); + await foreach (var client in store.GetAllClientsAsync()) + { + result.Add(client); + } + + result.Count.ShouldBe(2); + result.ShouldContain(c => c.ClientId == "valid1"); + result.ShouldContain(c => c.ClientId == "valid2"); + result.ShouldNotContain(c => c.ClientId == "invalid1"); + } + + [Fact] + public async Task GetAllClientsAsync_WhenClientIsInvalid_ShouldRaiseEvent() + { + var clients = new List + { + new() { ClientId = "invalid_client" } + }; + var innerStore = StubClientStore.WithClients(clients); + var validator = new StubClientConfigurationValidator(isValid: false, errorMessage: "Invalid configuration"); + var store = new ValidatingClientStore(innerStore, validator, _events, _logger); + + var result = new List(); + await foreach (var client in store.GetAllClientsAsync()) + { + result.Add(client); + } + + result.ShouldBeEmpty(); + _events.AssertEventWasRaised(); + } + + [Fact] + public async Task GetAllClientsAsync_WhenNoClients_ShouldReturnEmpty() + { + var innerStore = StubClientStore.Empty(); + var validator = new StubClientConfigurationValidator(isValid: true); + var store = new ValidatingClientStore(innerStore, validator, _events, _logger); + + var result = new List(); + await foreach (var client in store.GetAllClientsAsync()) + { + result.Add(client); + } + + result.ShouldBeEmpty(); + } + + [Fact] + public async Task GetAllClientsAsync_WhenAllClientsAreInvalid_ShouldReturnEmpty() + { + var clients = new List + { + new() { ClientId = "invalid1" }, + new() { ClientId = "invalid2" } + }; + var innerStore = StubClientStore.WithClients(clients); + var validator = new StubClientConfigurationValidator(isValid: false, errorMessage: "All invalid"); + // Use a stub event service that allows multiple events of the same type + var eventService = new StubEventService(); + var store = new ValidatingClientStore(innerStore, validator, eventService, _logger); + + var result = new List(); + await foreach (var client in store.GetAllClientsAsync()) + { + result.Add(client); + } + + result.ShouldBeEmpty(); + eventService.RaisedEventCount.ShouldBe(2); + } +#endif + + private class StubClientStore : IClientStore + { + private readonly Client? _client; + private readonly IEnumerable _clients; + + private StubClientStore(Client? client, IEnumerable clients) + { + _client = client; + _clients = clients; + } + + public static StubClientStore Empty() => new(null, []); + + public static StubClientStore WithClient(Client client) => new(client, [client]); + + public static StubClientStore WithClients(IEnumerable clients) => new(clients.FirstOrDefault(), clients); + + public Task FindClientByIdAsync(string clientId) => Task.FromResult(_client); + +#if NET10_0_OR_GREATER + public async IAsyncEnumerable GetAllClientsAsync() + { + foreach (var client in _clients) + { + yield return client; + } + } +#endif + } + + private class StubClientConfigurationValidator : IClientConfigurationValidator + { + private readonly bool _isValid; + private readonly string? _errorMessage; + private readonly Func? _validationFunc; + + public StubClientConfigurationValidator(bool isValid, string? errorMessage = null) + { + _isValid = isValid; + _errorMessage = errorMessage; + } + + public StubClientConfigurationValidator(Func validationFunc, string? errorMessage = null) + { + _validationFunc = validationFunc; + _errorMessage = errorMessage; + } + + public Task ValidateAsync(ClientConfigurationValidationContext context) + { + var isValid = _validationFunc != null ? _validationFunc(context.Client) : _isValid; + + if (!isValid) + { + context.SetError(_errorMessage ?? "Validation failed"); + } + + return Task.CompletedTask; + } + } + + private class StubEventService : IEventService + { + public int RaisedEventCount { get; private set; } + + public bool CanRaiseEventType(EventTypes evtType) => true; + + public Task RaiseAsync(Event evt) + { + RaisedEventCount++; + return Task.CompletedTask; + } + } +}