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