Added in Unreleased.
The built-in InMemoryClientRepository is suitable for development and simple deployments. For production scenarios where clients are stored in a database, loaded from an external config service, or managed via an admin API, implement IClientRepository directly.
The contract
public interface IClientRepository
{
ValueTask<IClientRegistration?> FindByClientIdAsync(
string clientId,
CancellationToken cancellationToken = default);
}
Two rules are non-negotiable:
-
Return
nullfor unknown clients — never throw. Throwing from this method changes response timing and creates a timing oracle that enables client enumeration attacks. An unknown or malformedclient_idmust always produce anullreturn, never an exception. -
Use parameterised queries — never concatenate
clientIdinto a query string. If your implementation queries a database, passclientIdas a parameter to prevent SQL injection.
Registering a custom repository
Register your implementation as a singleton via the standard DI API. The startup validator detects the registration automatically — no extra flags or configuration needed:
services.AddSingleton<IClientRepository, MyDatabaseClientRepository>();
Wrapping it in a builder extension is a clean pattern:
public static ZeeKayDaAuthBuilder AddDatabaseClients(this ZeeKayDaAuthBuilder builder)
{
builder.Services.AddSingleton<IClientRepository, MyDatabaseClientRepository>();
return builder;
}
Validating registrations
The framework validates every registration your repository returns, so FindByClientIdAsync never has to. A registration that fails is refused as an unknown client and logged for the operator.
Validating on write is still worth it. A bad registration found at read time surfaces on a live request, as an error the operator sees in the log, rather than when the row was written. The validator enforces all redirect URI rules, the IsPublic consistency check, credential integrity checks, and auth-method subset constraints. Inject IClientRegistrationValidator from DI and call it where you write:
public sealed class MyDatabaseClientRepository : IClientRepository
{
private readonly IClientRegistrationValidator _validator;
private readonly MyDbContext _db;
public MyDatabaseClientRepository(
IClientRegistrationValidator validator,
MyDbContext db)
{
_validator = validator;
_db = db;
}
public async Task SaveClientAsync(IClientRegistration client)
{
// Throws ZeeKayDaConfigurationException with all violations in AggregatedFailures
// if the registration is invalid — see every problem in one pass.
_validator.Validate(client);
// persist ...
}
public async ValueTask<IClientRegistration?> FindByClientIdAsync(
string clientId,
CancellationToken cancellationToken = default)
{
// Parameterised query — never concatenate clientId into the query string.
var row = await _db.Clients
.Where(c => c.ClientId == clientId)
.FirstOrDefaultAsync(cancellationToken);
return row is null ? null : MapToRegistration(row);
}
private static IClientRegistration MapToRegistration(ClientRow row) => new ClientRegistration
{
ClientId = row.ClientId,
Credentials = row.Secrets.Select(s => new Pbkdf2ClientSecret(
s.Iterations, s.Salt, s.Hash)).ToList<IClientCredential>(),
IsPublic = row.IsPublic,
RedirectUris = new HashSet<string>(row.RedirectUris, StringComparer.Ordinal),
PostLogoutRedirectUris = new HashSet<string>(row.PostLogoutRedirectUris, StringComparer.Ordinal),
AllowedScopes = new HashSet<string>(row.AllowedScopes, StringComparer.Ordinal),
};
}
String comparison invariants
The framework never hands your entity to its endpoints or to a token issuer. It copies each registration it looks up and rebuilds the IReadOnlySet<string> members — RedirectUris, PostLogoutRedirectUris, AllowedScopes, AllowedTokenEndpointAuthMethods — with StringComparer.Ordinal, whatever comparer your sets were built with. A registration the framework hands you, such as TokenIssuanceContext.Client, therefore compares ordinally.
Code that reads your entity directly gets no such guarantee. Wherever your repository, or any other code that resolves IClientRepository itself, checks membership in one of those sets, use explicit StringComparer.Ordinal semantics and count by enumerating. Do not trust the set’s own comparer or its Count: a set built with a case-insensitive or culture-aware comparer would silently loosen security boundaries.
// Correct — ordinal comparison regardless of the set's comparer
var allowed = entity.RedirectUris
.Contains(incomingRedirectUri, StringComparer.Ordinal);
// Wrong — trusts the set's comparer, which may differ across implementations
var allowed = entity.RedirectUris.Contains(incomingRedirectUri);
See also
- Register clients — the built-in in-memory repository for development and simple deployments.
- Implement a custom extension point — scope repository and discovery document customisation.