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
You must call IClientRegistrationValidator.Validate before persisting a new or updated client registration. The validator enforces all redirect URI rules, the IsPublic consistency check, credential integrity checks, and auth-method subset constraints. Not calling it means your clients bypass the security checks that the framework enforces on in-memory registrations.
Inject IClientRegistrationValidator from DI and call it at write time:
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),
};
}
For read-mostly stores (replicated databases, caches) where the initial load happens outside the request path, call Validate as registrations enter the cache — not just at the DB write that originally created them. A registration that fails validation must not be returned from FindByClientIdAsync.
String comparison invariants
All IReadOnlySet<string> members on IClientRegistration — RedirectUris, PostLogoutRedirectUris, AllowedScopes, AllowedTokenEndpointAuthMethods — must be enumerated with explicit StringComparer.Ordinal semantics wherever a membership check is performed. Do not trust the set’s own comparer: a custom implementation may return a set built with a case-insensitive or culture-aware comparer, which would silently loosen security boundaries.
// Correct — ordinal comparison regardless of the set's comparer
var allowed = client.RedirectUris
.Contains(incomingRedirectUri, StringComparer.Ordinal);
// Wrong — trusts the set's comparer, which may differ across implementations
var allowed = client.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.