MicroPlumberd.Services.Identity 1.2.3
Seeding
AddIdentitySeed declares the identity state that must exist after start-up. The library converges the store to
that declaration; it knows no role name and no user name of its own.
services.AddPlumberd(settings);
services.AddPlumberdIdentity();
services.AddIdentitySeed(seed => seed
.Role("Administrator")
.Role("Accountant")
.User("admin@localhost", u => u.WithUserName("admin").WithPassword(pwd).InRoles("Administrator"))
.User("audit@example.com", u => u.InRoles("Auditor")) // no password => external-login-only account
.Then(async (ctx, ct) => await ctx.EnsureRoleAsync("Reporting", ct))
.WaitUpTo(TimeSpan.FromSeconds(30)));
AddIdentitySeed may be called more than once; declarations accumulate in registration order and the hosted
runner (IdentityInitializerService) is registered exactly once. The last WaitUpTo wins.
Ensure semantics
Everything is idempotent and additive.
| Declaration | What the seed guarantees | What it never does |
|---|---|---|
Role(n) |
The role exists after the seed converged. | Rename it, delete it, or touch a role that is not declared. |
User(e) |
The user exists with EmailConfirmed = true and is a member of the declared roles. A role named in InRoles that was not declared with Role is ensured before the membership is assigned. |
Modify an existing user: no password reset, no role removal, no user-name change. |
Consequences worth stating:
- A role dropped from the declaration is left alone — the seed is additive, it does not converge downwards.
- A seeded user deleted by an operator is recreated on the next boot. To take an account out of service, lock it or change its password; do not delete it.
- Likewise a declared user whose e-mail an operator changed: the declared address is ensured as a new account. The declaration is keyed by e-mail, so changing it away from the declared value reads as "the declared user is missing".
- Uniqueness is enforced by the read models, not by the store. Two different hosts seeding the same empty store at the same instant can each create a role of the same name. Within one host — restarts, retries, concurrent attempts — the ensure steps are idempotent.
- The identity stores (
UserStore.AddToRoleAsync/RemoveFromRoleAsync/IsInRoleAsync) assume the default upper-invariantILookupNormalizer. A custom normalizer is unsupported end-to-end — the seed's own keys are normalizer-correct (RoleManager.NormalizeKey,UserManager.NormalizeEmail), but the stores are not.
Readiness, not time
The seed starts when the read models it consults (RolesModel, UsersModel, UserAuthorizationModel) report
ICaughtUpHandler.CaughtUp() — not after a Task.Delay. Each write then waits until its own write is visible
in the read model the next step reads (RoleManager.CreateAsync appends an event; AddToRoleAsync reads the role
back out of RolesModel, and a read taken before the fold is what used to throw
Role 'X' does not exist).
Both waits are bounded by one per-attempt bound, WaitUpTo (default 30 s). Expiry fails the attempt, never the
host: the attempt logs at Error, and the runner retries with backoff 1, 2, 5, 10, 20, 30, 30… seconds until it
converges or the host stops. TimeProvider is resolved from DI when registered, so tests can compress the backoff.
Observing it
IdentityInitializerService.State is an IdentitySeedState(bool Ready, string Description, int Attempts, string? LastError)
snapshot, and IdentityInitializerService.Completed is a Task that completes when the seed converged.
services.AddHealthChecks().AddIdentitySeedHealthCheck(); // entry "identity", untagged
The health check is opt-in: a patch upgrade must not silently add a /health entry to every consumer. It reads
State live — Unhealthy naming the current step or the last error until the seed converged, Healthy afterwards.
HostOptions.BackgroundServiceExceptionBehavior — state it
The .NET default is BackgroundServiceExceptionBehavior.StopHost: one background service that throws out of
ExecuteAsync takes the whole host down. In a container that is a restart loop, and /health stays green right up
to the moment the process exits.
IdentityInitializerService never lets an exception escape ExecuteAsync, so it cannot trigger that on its own —
but the option is host-wide and a library cannot set it on a consumer's behalf. Set it explicitly, whichever way
you want it, so it is a stated choice and not an accident:
services.Configure<HostOptions>(o =>
o.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore);
Non-web hosts must add data protection
AddPlumberdIdentity calls AddDefaultTokenProviders(), which registers DataProtectorTokenProvider<User>. The
UserManager<TUser> constructor resolves every provider in Options.Tokens.ProviderMap, so UserManager<User>
cannot be constructed without an IDataProtectionProvider. A web host gets one from WebApplicationBuilder; a
generic host (worker service, test host, Host.CreateDefaultBuilder()) does not, and the seed fails every attempt
with:
Unable to resolve service for type 'Microsoft.AspNetCore.DataProtection.IDataProtectionProvider'
while attempting to activate 'Microsoft.AspNetCore.Identity.DataProtectorTokenProvider`1[...User]'
The library does not register it for you — neither does ASP.NET Core's own AddIdentity/AddIdentityCore. In a
non-web host, add it yourself:
services.AddDataProtection(); // plus a key-persistence choice if the host is not ephemeral
Compatibility
AddIdentityInitializer(o => …) and IdentityInitializerOptions keep their names and meaning. The call is now an
adapter over the seed: Role(AdminRoleName),
User(AdminEmail, WithUserName(AdminUserName), WithPassword(AdminPassword), InRoles(AdminRoleName)),
WaitUpTo(ProjectionWaitTime). SeedAdminUser = false contributes nothing, so the seed is ready immediately.
ProjectionWaitTime is no longer a delay — it is the per-attempt readiness bound.
Specifications for Integrating ASP.NET Core Identity with MicroPlumberd and EventStore Introduction This document outlines the specifications for integrating ASP.NET Core Identity with MicroPlumberd and EventStore in an ASP.NET Core application. The integration uses event sourcing (ES) and Command Query Responsibility Segregation (CQRS) to manage user and role data, providing scalability, auditability, and consistency. Designed with a Blazor application in mind, the solution is adaptable to any ASP.NET Core app. It supports all ASP.NET Core Identity features�user management, roles, claims, external logins, and two-factor authentication�while adhering to domain-driven design (DDD) principles. Architecture Overview The integration follows a CQRS/ES architecture: Aggregates: Handle the write side, encapsulating domain logic and emitting events to EventStore via MicroPlumberd. Aggregates respond to method invocations.
Read Models: Handle the read side, subscribing to events and maintaining query-optimized data structures that can span multiple aggregates for efficient querying.
Stores: Implement ASP.NET Core Identity store interfaces, bridging aggregates (writes) and read models (reads).
Eventual consistency is embraced, with read models updating asynchronously as events are processed. Given EventStore�s low latency, this is suitable for most Identity operations. Value Types Value types enhance type safety and expressiveness: UserIdentifier: record struct UserIdentifier(Guid Id) � Uniquely identifies a user.
RoleIdentifier: record struct RoleIdentifier(Guid Id) � Uniquely identifies a role.
ClaimType: record struct ClaimType(string Value) � Represents a claim type (e.g., "Permission").
ClaimValue: record struct ClaimValue(string Value) � Represents a claim value (e.g., "Read").
ExternalLoginProvider: record struct ExternalLoginProvider(string Name) � Identifies an external login provider (e.g., "Google").
ExternalLoginKey: record struct ExternalLoginKey(string Value) � Identifies a user within an external provider.
TokenName: record struct TokenName(string Value) � Names a token (e.g., "RefreshToken").
TokenValue: record struct TokenValue(string Value) � Holds a token�s value.
These types prevent misuse of primitive strings or GUIDs and clarify domain intent. Aggregates Aggregates enforce business rules and maintain consistency within their boundaries. Each operates on a specific event stream and handles a distinct set of responsibilities.
- IdentityUserAggregate Stream: UserIdentity-
Purpose: Manages user authentication data (passwords, lockouts, two-factor settings).
Business Rules: Passwords must be provided as plaintext and hashed internally (e.g., using PBKDF2); direct hash setting is disallowed.
If LockoutEnabled is true and LockoutEnd is in the future, login is blocked.
Increments AccessFailedCount on failed login attempts; resets it to 0 on success.
Triggers lockout when AccessFailedCount reaches a threshold (e.g., 5).
Updating the security stamp invalidates all user sessions.
Two-factor authentication requires a valid authenticator key to enable; disabling removes the key.
Validation Rules: Password Hash: Must be a valid hash string (format depends on the algorithm).
Security Stamp: Must be non-empty.
LockoutEnd: If set, must be a future date.
Authenticator Key: Must be a valid format (e.g., Base32 for TOTP) if two-factor is enabled.
- UserProfileAggregate Stream: UserProfile-
Purpose: Manages user profile data (username, email, phone).
Business Rules: EmailConfirmed requires a verified confirmation process (e.g., email token).
PhoneNumberConfirmed requires verification (e.g., SMS code).
Validation Rules: Username: Non-empty, unique (checked via read model).
Email: Valid format (e.g., regex), unique (checked via read model).
Phone Number: If provided, must match a valid format (e.g., E.164).
- AuthorizationUserAggregate Stream: UserAuthorization-
Purpose: Manages user authorization data (roles, claims).
Business Rules: Supports adding/removing roles (role existence checked via read models).
Supports adding/removing claims; ensures claim uniqueness by type within the user�s set.
Validation Rules: Roles: Non-empty strings.
Claims: ClaimType and ClaimValue must be non-empty.
- ExternalLoginAggregate Stream: UserExternalLogins-
Purpose: Manages external login providers linked to a user.
Business Rules: Ensures each ExternalLoginProvider and ExternalLoginKey combination is unique per user.
Validation Rules: Provider: Non-empty (e.g., "Google").
Key: Non-empty (provided by the external provider).
- TokenAggregate Stream: UserTokens-
Purpose: Manages authentication tokens (e.g., refresh tokens).
Business Rules: Ensures TokenName uniqueness within the user�s token set.
Validation Rules: Token Name: Non-empty (e.g., "RefreshToken").
Token Value: Non-empty (e.g., a secure token).
- RoleAggregate Stream: Role-
Purpose: Manages system-wide roles.
Business Rules: Ensures role name uniqueness (checked via read model).
Validation Rules: Name: Non-empty (e.g., "Admin").
NormalizedName: Uppercase version of Name (e.g., "ADMIN").
Read Models Read models are event-driven projections optimized for querying, capable of spanning multiple aggregates to provide a unified view. Below are the read models and their query methods: User-Related Read Models UserByIdModel Query: User GetById(UserIdentifier id)
Purpose: Retrieves a complete user object for FindByIdAsync.
UserByNameModel Query: UserIdentifier GetIdByNormalizedUsername(string normalizedUsername)
Purpose: Maps a normalized username to a user ID for FindByNameAsync.
UserByEmailModel Query: UserIdentifier GetIdByNormalizedEmail(string normalizedEmail)
Purpose: Maps a normalized email to a user ID for FindByEmailAsync.
UserByLoginModel Query: UserIdentifier GetIdByLogin(string provider, string key)
Purpose: Finds a user by external login for FindByLoginAsync.
UsersInRoleModel
Query: IEnumerable
Purpose: Lists users in a role for GetUsersInRoleAsync.
UserClaimsModel
Query: IEnumerable
Purpose: Retrieves user claims for GetClaimsAsync.
AuthenticationModel Query: AuthenticationData GetAuthenticationData(UserIdentifier id)
Purpose: Provides authentication data (e.g., password hash) for login checks.
UserQueryableModel
Query: IQueryable
Purpose: Enables advanced user searches via IQueryableUserStore
Role-Related Read Models RoleByIdModel Query: Role GetById(RoleIdentifier id)
Purpose: Retrieves a role by ID.
RoleByNameModel Query: RoleIdentifier GetIdByNormalizedName(string normalizedName)
Purpose: Maps a normalized role name to a role ID for FindByNameAsync.
RoleQueryableModel
Query: IQueryable
Purpose: Enables role queries via IQueryableRoleStore
Configuration
Dependency Injection
Read Models: Register as singletons (AddSingleton
MicroPlumberd: Register per its documentation (e.g., AddScoped<IPlumber, Plumber>()).
Stores: Register as scoped services (AddScoped
ASP.NET Core Identity Setup Configure Identity with custom stores: csharp
builder.Services.AddIdentity<User, Role>()
.AddUserStore
Testing Unit Tests: Test aggregate methods (e.g., IdentityUserAggregate.SetPasswordHash emits correct events).
Verify read model updates with simulated event streams.
Integration Tests: Use an in-memory EventStore to test end-to-end flows (e.g., user creation, login).
Eventual Consistency: Simulate delays to ensure system resilience.
Design Rationale Aggregate Split: User data is divided into IdentityUserAggregate, UserProfileAggregate, and AuthorizationUserAggregate to keep responsibilities focused and aggregates manageable, supporting future complexity (e.g., Active Directory integration).
Querying: Read models span aggregates, ensuring flexible and efficient queries without compromising write-side consistency.
Eventual Consistency: Accepted due to EventStore�s low latency; critical operations can use application-layer checks if needed.
Coordination: Sagas/process managers (e.g., CreateUserSaga) handle multi-aggregate operations like user creation. (But the inital implementation shall skip hard process-menagers)
Memo: Concurrency Control with Composite Version in Event-Sourced ASP.NET Core Identity
Date: [Insert Date]
Subject: Decision on Concurrency Control Using Composite Version for Aggregates
Audience: Development Team
Background
In our event-sourced system, user data is managed across three distinct aggregates:
IdentityUserAggregate: Handles authentication-related data (e.g., passwords, lockouts).
UserProfileAggregate: Manages user profile information (e.g., email, username).
AuthorizationUserAggregate: Controls roles and claims.
Each aggregate maintains its own event stream and version number (tracked as a long via Metadata.SourceStreamPosition). However, ASP.NET Core Identity expects a single ConcurrencyStamp (typically a GUID) for concurrency control on the IdentityUser object. To bridge this gap, we�ve decided to implement a composite version approach. Decision We will adopt a composite version to unify the versioning of all three aggregates into a single ConcurrencyStamp. This composite version will: Be represented as a string in the format "identityVersion.profileVersion.authorizationVersion" (e.g., "1.2.3").
Enable extraction of individual aggregate versions for precise concurrency checks.
Be maintained in read models, updated as events from each aggregate�s stream are processed.
This approach ensures: Compatibility with ASP.NET Core Identity�s ConcurrencyStamp requirement.
Granular control over concurrency at the aggregate level.
Simplicity in tracking and verifying versions.
Implementation Details
- Composite Version Structure A CompositeVersion value type will be created with: Properties: IdentityVersion (long): Version of the IdentityUserAggregate.
ProfileVersion (long): Version of the UserProfileAggregate.
AuthorizationVersion (long): Version of the AuthorizationUserAggregate.
Methods: ToString(): Outputs the composite version as a string (e.g., "1.2.3").
Parse(string): Converts a string back into a CompositeVersion.
GetVersionFor(AggregateType): Retrieves the version for a specified aggregate.
- Read Model Updates Read models (e.g., UserByIdModel) will update the composite version as events are processed from each aggregate.
The IdentityUser.ConcurrencyStamp property will store the stringified composite version.
- Concurrency Validation in UserStore Parse the incoming ConcurrencyStamp into a CompositeVersion.
Compare it against the current composite version from the read model: If they differ, reject the update due to a concurrency conflict.
Use GetVersionFor to extract the relevant aggregate�s version and pass it as the expectedVersion when appending events.
Special Instruction for Initial Implementation For the first implementation of any aggregate, skip the ConcurrencyStamp check. This decision is made because: Early implementations may lack fully populated read models or event streams.
Skipping the check prevents unnecessary concurrency failures during initial development and testing.
Once the system stabilizes and all aggregates are fully integrated, the ConcurrencyStamp check should be activated to enforce proper concurrency control. Rationale Event Sourcing Alignment: Utilizes existing aggregate versioning, avoiding redundant concurrency mechanisms.
ASP.NET Core Identity Support: Meets the framework�s ConcurrencyStamp requirement seamlessly.
Independent Updates: Allows each aggregate to evolve independently while preserving user data consistency.
This memo should be added to specs.md under a section such as "Concurrency Control Decisions" or "Implementation Notes" to document our approach and guide the team effectively.
Showing the top 20 packages that depend on MicroPlumberd.Services.Identity.
| Packages | Downloads |
|---|---|
|
MicroPlumberd.Services.Identity.Blazor
Blazor components for MicroPlumberd.Services.Identity - User and Role management UI
|
6 |
|
MicroPlumberd.Services.Identity.Blazor
Blazor components for MicroPlumberd.Services.Identity - User and Role management UI
|
5 |
|
MicroPlumberd.Services.Identity.Blazor
Blazor components for MicroPlumberd.Services.Identity - User and Role management UI
|
4 |
|
MicroPlumberd.Services.Identity.Blazor
Blazor components for MicroPlumberd.Services.Identity - User and Role management UI
|
3 |
.NET 10.0
- MicroPlumberd.Services (>= 1.2.3)
- MicroPlumberd (>= 1.2.3)
| Version | Downloads | Last updated |
|---|---|---|
| 1.2.4 | 0 | 08/20/2026 |
| 1.2.3 | 3 | 08/17/2026 |
| 1.2.2 | 5 | 07/31/2026 |
| 1.2.1 | 2 | 07/16/2026 |
| 1.2.0 | 4 | 05/11/2026 |
| 1.1.8.3 | 5 | 03/23/2026 |
| 1.1.8.2 | 6 | 03/23/2026 |
| 1.1.8.1 | 6 | 03/23/2026 |
| 1.1.8 | 4 | 04/25/2026 |
| 1.1.7 | 4 | 04/25/2026 |
| 1.1.6.8 | 7 | 03/31/2026 |
| 1.1.6.7 | 4 | 04/25/2026 |
| 1.1.6.6 | 3 | 04/25/2026 |
| 1.1.6.5 | 4 | 04/25/2026 |
| 1.1.6.4 | 3 | 04/25/2026 |
| 1.1.6.3 | 4 | 04/25/2026 |
| 1.1.6.2 | 4 | 04/25/2026 |
| 1.1.6 | 3 | 04/25/2026 |
| 1.1.5.1-preview | 4 | 04/25/2026 |
| 1.1.5-preview | 3 | 04/25/2026 |
| 1.1.4.1-preview | 3 | 04/25/2026 |
| 1.1.4 | 3 | 04/25/2026 |
| 1.1.3.5 | 4 | 04/25/2026 |
| 1.1.3.4 | 4 | 04/25/2026 |
| 1.1.3.3 | 3 | 04/25/2026 |
| 1.1.3.2 | 4 | 04/25/2026 |
| 1.1.3.1 | 3 | 04/25/2026 |
| 1.1.3 | 4 | 04/25/2026 |
| 1.1.2 | 4 | 04/25/2026 |
| 1.1.1 | 4 | 04/25/2026 |
| 1.1.0.1 | 4 | 04/25/2026 |
| 1.1.0 | 4 | 04/25/2026 |
| 1.0.126.12 | 4 | 04/25/2026 |
| 1.0.126.6 | 4 | 04/25/2026 |
| 1.0.126.5 | 5 | 04/25/2026 |
| 1.0.126.4 | 4 | 04/25/2026 |
| 1.0.126.3 | 4 | 04/25/2026 |
| 1.0.126.2 | 4 | 04/25/2026 |
| 1.0.126.1 | 4 | 04/25/2026 |
| 1.0.126 | 4 | 04/25/2026 |
| 1.0.125 | 4 | 04/25/2026 |
| 1.0.124.158 | 5 | 04/25/2026 |
| 1.0.123.158 | 3 | 04/25/2026 |
| 1.0.122.155 | 4 | 04/25/2026 |
| 1.0.122.154 | 4 | 04/25/2026 |
| 1.0.121.154 | 5 | 04/25/2026 |
| 1.0.120.153 | 4 | 04/25/2026 |
| 1.0.118.151 | 3 | 04/25/2026 |
| 1.0.117.151 | 4 | 04/25/2026 |
| 1.0.115.150 | 4 | 04/25/2026 |
| 1.0.114.150 | 4 | 04/25/2026 |
| 1.0.113.149 | 4 | 04/25/2026 |
| 1.0.112.149 | 5 | 04/25/2026 |
| 1.0.111.147 | 4 | 04/25/2026 |