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"
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/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/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
+}
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;
+ }
+ }
+}