Merge pull request #2331 from DuendeSoftware/dh/client-enumeration-store

Add GetAllClientsAsync to IClientStore for client enumeration
This commit is contained in:
Damian Hickey 2026-01-26 17:18:36 +01:00 committed by GitHub
commit e65c97126d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 774 additions and 331 deletions

View file

@ -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"

View file

@ -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;
/// <summary>
/// Implementation of IClientStore thats uses EF.
/// </summary>
/// <seealso cref="IClientStore" />
public class ClientStore : IClientStore
{
/// <summary>
/// The DbContext.
/// </summary>
protected readonly IConfigurationDbContext Context;
/// <summary>
/// The CancellationToken provider.
/// </summary>
protected readonly ICancellationTokenProvider CancellationTokenProvider;
/// <summary>
/// The logger.
/// </summary>
protected readonly ILogger<ClientStore> Logger;
/// <summary>
/// Initializes a new instance of the <see cref="ClientStore"/> class.
/// </summary>
/// <param name="context">The context.</param>
/// <param name="logger">The logger.</param>
/// <param name="cancellationTokenProvider"></param>
/// <exception cref="ArgumentNullException">context</exception>
public ClientStore(IConfigurationDbContext context, ILogger<ClientStore> logger, ICancellationTokenProvider cancellationTokenProvider)
{
Context = context ?? throw new ArgumentNullException(nameof(context));
Logger = logger;
CancellationTokenProvider = cancellationTokenProvider;
}
/// <summary>
/// Finds a client by id
/// </summary>
/// <param name="clientId">The client id</param>
/// <returns>
/// The client
/// </returns>
public virtual async Task<Duende.IdentityServer.Models.Client> 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;
/// <summary>
/// Implementation of IClientStore thats uses EF.
/// </summary>
/// <seealso cref="IClientStore" />
public class ClientStore : IClientStore
{
/// <summary>
/// The DbContext.
/// </summary>
protected readonly IConfigurationDbContext Context;
/// <summary>
/// The CancellationToken provider.
/// </summary>
protected readonly ICancellationTokenProvider CancellationTokenProvider;
/// <summary>
/// The logger.
/// </summary>
protected readonly ILogger<ClientStore> Logger;
/// <summary>
/// Initializes a new instance of the <see cref="ClientStore"/> class.
/// </summary>
/// <param name="context">The context.</param>
/// <param name="logger">The logger.</param>
/// <param name="cancellationTokenProvider"></param>
/// <exception cref="ArgumentNullException">context</exception>
public ClientStore(IConfigurationDbContext context, ILogger<ClientStore> logger, ICancellationTokenProvider cancellationTokenProvider)
{
Context = context ?? throw new ArgumentNullException(nameof(context));
Logger = logger;
CancellationTokenProvider = cancellationTokenProvider;
}
/// <summary>
/// Finds a client by id
/// </summary>
/// <param name="clientId">The client id</param>
/// <returns>
/// The client
/// </returns>
public virtual async Task<Duende.IdentityServer.Models.Client> 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
/// <inheritdoc/>
public virtual async IAsyncEnumerable<Duende.IdentityServer.Models.Client> 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
}

View file

@ -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
/// <inheritdoc/>
public async IAsyncEnumerable<Client> GetAllClientsAsync()
{
using var activity = Tracing.StoreActivitySource.StartActivity("InMemoryClientStore.GetAllClients");
foreach (var client in _clients)
{
yield return client;
}
}
#endif
}

View file

@ -77,4 +77,31 @@ public class ValidatingClientStore<T> : IClientStore
return null;
}
#if NET10_0_OR_GREATER
/// <inheritdoc/>
public async IAsyncEnumerable<Client> 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
}

View file

@ -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;
/// <summary>
/// Retrieval of client configuration
/// </summary>
public interface IClientStore
{
/// <summary>
/// Finds a client by id
/// </summary>
/// <param name="clientId">The client id</param>
/// <returns>The client</returns>
Task<Client?> 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;
/// <summary>
/// Retrieval of client configuration
/// </summary>
public interface IClientStore
{
/// <summary>
/// Finds a client by id
/// </summary>
/// <param name="clientId">The client id</param>
/// <returns>The client</returns>
Task<Client?> FindClientByIdAsync(string clientId);
#if NET10_0_OR_GREATER
/// <summary>
/// Returns all clients for enumeration purposes (e.g., conformance assessment).
/// This method has a default implementation that throws <see cref="NotSupportedException"/>.
/// </summary>
/// <returns>An async enumerable of all clients.</returns>
IAsyncEnumerable<Client> GetAllClientsAsync()
=> throw new NotSupportedException("Client enumeration is not supported by this store implementation.");
#endif
}

View file

@ -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<ClientStoreTests, ConfigurationDbContext, ConfigurationStoreOptions>
{
public ClientStoreTests(DatabaseProviderFixture<ConfigurationDbContext> 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<ConfigurationDbContext> options)
{
await using var context = new ConfigurationDbContext(options);
var store = new ClientStore(context, new NullLogger<ClientStore>(), new NoneCancellationTokenProvider());
var client = await store.FindClientByIdAsync(Guid.NewGuid().ToString());
client.ShouldBeNull();
}
[Theory, MemberData(nameof(TestDatabaseProviders))]
public async Task FindClientByIdAsync_WhenClientExists_ExpectClientReturned(DbContextOptions<ConfigurationDbContext> 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<ClientStore>(), new NoneCancellationTokenProvider());
client = await store.FindClientByIdAsync(testClient.ClientId);
}
client.ShouldNotBeNull();
}
[Theory, MemberData(nameof(TestDatabaseProviders))]
public async Task FindClientByIdAsync_WhenClientExistsWithCollections_ExpectClientReturnedCollections(DbContextOptions<ConfigurationDbContext> 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<ClientStore>(), 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<ConfigurationDbContext> 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<ClientStore>(), 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<ClientStoreTests, ConfigurationDbContext, ConfigurationStoreOptions>
{
public ClientStoreTests(DatabaseProviderFixture<ConfigurationDbContext> 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<ConfigurationDbContext> options)
{
await using var context = new ConfigurationDbContext(options);
var store = new ClientStore(context, new NullLogger<ClientStore>(), new NoneCancellationTokenProvider());
var client = await store.FindClientByIdAsync(Guid.NewGuid().ToString());
client.ShouldBeNull();
}
[Theory, MemberData(nameof(TestDatabaseProviders))]
public async Task FindClientByIdAsync_WhenClientExists_ExpectClientReturned(DbContextOptions<ConfigurationDbContext> 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<ClientStore>(), new NoneCancellationTokenProvider());
client = await store.FindClientByIdAsync(testClient.ClientId);
}
client.ShouldNotBeNull();
}
[Theory, MemberData(nameof(TestDatabaseProviders))]
public async Task FindClientByIdAsync_WhenClientExistsWithCollections_ExpectClientReturnedCollections(DbContextOptions<ConfigurationDbContext> 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<ClientStore>(), 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<ConfigurationDbContext> 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<ClientStore>(), 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<ConfigurationDbContext> options)
{
await using var context = new ConfigurationDbContext(options);
var store = new ClientStore(context, new NullLogger<ClientStore>(), new NoneCancellationTokenProvider());
var clients = new List<Client>();
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<ConfigurationDbContext> options)
{
var testClients = new List<Client>
{
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<ClientStore>(), new NoneCancellationTokenProvider());
var clients = new List<Client>();
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<ConfigurationDbContext> 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<ClientStore>(), new NoneCancellationTokenProvider());
var clients = new List<Client>();
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
}

View file

@ -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<Client>
{
new Client { ClientId = "1"},
new Client { ClientId = "1"},
new Client { ClientId = "3"}
};
Action act = () => new InMemoryClientStore(clients);
act.ShouldThrow<ArgumentException>();
}
[Fact]
public void InMemoryClient_should_not_throw_if_does_not_contain_duplicate_client_ids()
{
var clients = new List<Client>
{
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<Client>
{
new Client { ClientId = "1"},
new Client { ClientId = "1"},
new Client { ClientId = "3"}
};
Action act = () => new InMemoryClientStore(clients);
act.ShouldThrow<ArgumentException>();
}
[Fact]
public void InMemoryClient_should_not_throw_if_does_not_contain_duplicate_client_ids()
{
var clients = new List<Client>
{
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<Client>
{
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<Client>();
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<Client>();
var store = new InMemoryClientStore(clients);
var result = new List<Client>();
await foreach (var client in store.GetAllClientsAsync())
{
result.Add(client);
}
result.ShouldNotBeNull();
result.ShouldBeEmpty();
}
#endif
}

View file

@ -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<ValidatingClientStore<StubClientStore>> _logger = new();
#if NET10_0_OR_GREATER
[Fact]
public async Task GetAllClientsAsync_WhenAllClientsAreValid_ShouldReturnAllClients()
{
var clients = new List<Client>
{
new() { ClientId = "client1" },
new() { ClientId = "client2" },
new() { ClientId = "client3" }
};
var innerStore = StubClientStore.WithClients(clients);
var validator = new StubClientConfigurationValidator(isValid: true);
var store = new ValidatingClientStore<StubClientStore>(innerStore, validator, _events, _logger);
var result = new List<Client>();
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<Client>
{
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<StubClientStore>(innerStore, validator, _events, _logger);
var result = new List<Client>();
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<Client>
{
new() { ClientId = "invalid_client" }
};
var innerStore = StubClientStore.WithClients(clients);
var validator = new StubClientConfigurationValidator(isValid: false, errorMessage: "Invalid configuration");
var store = new ValidatingClientStore<StubClientStore>(innerStore, validator, _events, _logger);
var result = new List<Client>();
await foreach (var client in store.GetAllClientsAsync())
{
result.Add(client);
}
result.ShouldBeEmpty();
_events.AssertEventWasRaised<InvalidClientConfigurationEvent>();
}
[Fact]
public async Task GetAllClientsAsync_WhenNoClients_ShouldReturnEmpty()
{
var innerStore = StubClientStore.Empty();
var validator = new StubClientConfigurationValidator(isValid: true);
var store = new ValidatingClientStore<StubClientStore>(innerStore, validator, _events, _logger);
var result = new List<Client>();
await foreach (var client in store.GetAllClientsAsync())
{
result.Add(client);
}
result.ShouldBeEmpty();
}
[Fact]
public async Task GetAllClientsAsync_WhenAllClientsAreInvalid_ShouldReturnEmpty()
{
var clients = new List<Client>
{
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<StubClientStore>(innerStore, validator, eventService, _logger);
var result = new List<Client>();
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<Client> _clients;
private StubClientStore(Client? client, IEnumerable<Client> 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<Client> clients) => new(clients.FirstOrDefault(), clients);
public Task<Client?> FindClientByIdAsync(string clientId) => Task.FromResult(_client);
#if NET10_0_OR_GREATER
public async IAsyncEnumerable<Client> 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<Client, bool>? _validationFunc;
public StubClientConfigurationValidator(bool isValid, string? errorMessage = null)
{
_isValid = isValid;
_errorMessage = errorMessage;
}
public StubClientConfigurationValidator(Func<Client, bool> 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;
}
}
}