Incendia.Identity.Mongo
10.1.0
dotnet add package Incendia.Identity.Mongo --version 10.1.0
NuGet\Install-Package Incendia.Identity.Mongo -Version 10.1.0
<PackageReference Include="Incendia.Identity.Mongo" Version="10.1.0" />
<PackageVersion Include="Incendia.Identity.Mongo" Version="10.1.0" />
<PackageReference Include="Incendia.Identity.Mongo" />
paket add Incendia.Identity.Mongo --version 10.1.0
#r "nuget: Incendia.Identity.Mongo, 10.1.0"
#:package Incendia.Identity.Mongo@10.1.0
#addin nuget:?package=Incendia.Identity.Mongo&version=10.1.0
#tool nuget:?package=Incendia.Identity.Mongo&version=10.1.0
Incendia.Identity.Mongo
A MongoDB store for ASP.NET Core Identity that writes only what changed.
Adding a claim sends $push of that claim. Changing an email sends $set of the email fields. Removing a passkey
sends $pull of that passkey. Every write is guarded by the user's ConcurrencyStamp, so concurrent requests
never silently overwrite each other — exactly the guarantees of the EF Core stores, on a document database.
Works with the standard IdentityUser / IdentityRole (or your own subclasses), UserManager, RoleManager
and SignInManager. Built on Incendia.MongoTracker.
dotnet add package Incendia.Identity.Mongo
Your model stays free of MongoDB
Other MongoDB providers for Identity make your user and role classes inherit their own base types
(MongoUser<TKey> / MongoRole<TKey> in AspNetCore.Identity.Mongo, MongoIdentityUser<TKey> /
MongoIdentityRole<TKey> in AspNetCore.Identity.MongoDbCore). The class your application, domain and API code pass
around then depends on a storage library.
Here there are no special base classes: the stores work with the standard IdentityUser<TKey> /
IdentityRole<TKey>, or your own subclasses, exactly as they are. Everything MongoDB-specific — the document
layout, change tracking, concurrency checks — lives inside the stores and their registration, so the rest of the
application does not need to know which database Identity uses. That is how storage is best kept anyway: behind
the Identity abstractions, not in the model.
It also makes switching easy. An application built on ASP.NET Core Identity — for example with the EF Core
stores — moves to MongoDB by replacing the store registration with AddMongoStores: the user and role types and all
code working through UserManager, RoleManager and SignInManager stay unchanged. What does need attention is
outside that code: existing users and roles have to be migrated to the new collections, and any query written
directly against the DbContext has to use the Users / Roles queries instead.
Contents
- Why
- Quick start
- What is written to MongoDB
- Concurrency
- Document layout
- Your own user and role types
- Identifiers
- Indexes
- User-only mode, custom stores
- Requirements and limitations
Why
Most MongoDB Identity providers save a user by replacing the whole document. That works, but every change — a failed login counter, a new claim, a refreshed token — rewrites the entire user with all its claims, logins and tokens, and two requests that changed different things can only be told apart by the concurrency stamp.
This provider tracks changes instead and sends minimal atomic updates, while keeping the Identity semantics:
| Incendia.Identity.Mongo | AspNetCore.Identity.Mongo | AspNetCore.Identity.MongoDbCore | EF Core Identity stores + MongoDB EF Core provider | |
|---|---|---|---|---|
| User and role types | Standard IdentityUser<TKey> / IdentityRole<TKey> or your subclasses |
Must inherit MongoUser<TKey> / MongoRole<TKey> |
Must inherit MongoIdentityUser<TKey> / MongoIdentityRole<TKey> |
Standard IdentityUser<TKey> / IdentityRole<TKey> |
| How a user is saved | Minimal $set / $push / $pull |
ReplaceOne of the whole user |
ReplaceOne of the whole user; claims, logins and tokens rewritten as whole arrays |
Role and claim queries (GetRolesAsync, GetUsersInRoleAsync, GetUsersForClaimAsync) use joins, which the provider does not support |
ConcurrencyStamp check |
Every write | Update and delete | Update and delete | Every write |
| Passkeys (.NET 10) | ✅ | — | — | Supported by the stores |
<sub>Compared with the latest sources of
AspNetCore.Identity.Mongo and
AspNetCore.Identity.MongoDbCore at the time of
writing, and with the EF Core Identity UserStore (GetRolesAsync, GetUsersInRoleAsync, GetUsersForClaimAsync
use LINQ joins).</sub>
Quick start
builder.Services
.AddIdentity<IdentityUser, IdentityRole>(options =>
{
options.Lockout.MaxFailedAccessAttempts = 5;
options.SignIn.RequireConfirmedEmail = true;
})
.AddMongoStores(options =>
{
options.Database = mongoClient.GetDatabase("app");
// Optional
options.UsersCollection = "Users"; // default
options.RolesCollection = "Roles"; // default
options.CreateIndexes = true; // see "Indexes"
})
.AddDefaultTokenProviders();
That's it — use UserManager, RoleManager and SignInManager as usual:
var user = new IdentityUser("egor") { Email = "egor@example.com" };
await userManager.CreateAsync(user, password);
await userManager.AddToRoleAsync(user, "admin");
await userManager.AddClaimAsync(user, new Claim("tier", "gold"));
await userManager.AddLoginAsync(user, new UserLoginInfo("Google", googleId, "Google"));
What is written to MongoDB
Each UserManager / RoleManager call that changes a user sends one update with only the affected fields
and elements, filtered by _id and the ConcurrencyStamp the user was loaded with.
These are the actual updates, recorded by the integration tests of this repository:
| Call | Update sent |
|---|---|
AddClaimAsync(user, new Claim("tier", "gold")) |
$push: { Claims: { ClaimType: "tier", ClaimValue: "gold" } } |
AddClaimsAsync(user, [a, b]) |
$push: { Claims: { $each: [a, b] } } |
RemoveClaimAsync(user, claim) |
$pull: { Claims: { ClaimType: …, ClaimValue: … } } |
AddLoginAsync(user, login) |
$push: { Logins: { LoginProvider, ProviderKey, ProviderDisplayName } } |
RemoveLoginAsync(user, …) |
$pull: { Logins: … } + $set of the new User.SecurityStamp |
AddToRoleAsync(user, "admin") |
$push: { Roles: "<role id>" } |
RemoveFromRoleAsync(user, "admin") |
$pull: { Roles: "<role id>" } |
SetAuthenticationTokenAsync(user, …) (new token) |
$push: { Tokens: { LoginProvider, Name, Value } } |
SetEmailAsync(user, email) |
$set: { User.Email, User.NormalizedEmail, User.SecurityStamp } |
AccessFailedAsync(user) |
$set: { User.AccessFailedCount: 1 } |
AddOrUpdatePasskeyAsync(user, passkey) (new) |
$push: { Passkeys: { CredentialId, Data } } |
AddOrUpdatePasskeyAsync(user, passkey) (sign-in) |
$set: { "Passkeys.$[i0].Data.SignCount": … } with arrayFilters: [{ "i0.CredentialId": … }] |
RemovePasskeyAsync(user, credentialId) |
$pull: { Passkeys: { CredentialId: { $in: [ … ] } } } |
RoleManager.SetRoleNameAsync + UpdateAsync |
$set: { Role.Name, Role.NormalizedName } |
RoleManager.AddClaimAsync(role, claim) |
$push: { Claims: … } |
Every update also sets the new User.ConcurrencyStamp (or Role.ConcurrencyStamp), as Identity requires.
Two operations replace an array instead of changing one element, because MongoDB cannot add and remove elements of
the same array in a single update: ReplaceClaimAsync, and updating the value of an existing token
(SetAuthenticationTokenAsync, authenticator key, recovery codes). They are still guarded by the concurrency stamp.
Concurrency
Every update and delete is filtered by the ConcurrencyStamp the user (or role) had when it was loaded. If another
request changed or deleted it in the meantime, the operation returns IdentityErrorDescriber.ConcurrencyFailure,
exactly as with the EF Core stores:
// Request A and request B loaded the same user
await usersA.AddClaimAsync(userA, new Claim("tier", "gold")); // Succeeded
await usersB.AddClaimAsync(userB, new Claim("lang", "ru")); // Failed: ConcurrencyFailure, nothing overwritten
Users and roles may come from anywhere, including the Users and Roles queries of an admin page. An instance the
store has not loaded itself is compared with the stored document, so all its changes are saved; a copy older than
the stored document is rejected with ConcurrencyFailure.
Passkeys are addressed by their credential id, never by their position in the array, so a passkey is always changed or removed precisely, whatever happened to the others.
Document layout
A user is stored as one document. The Identity user is kept as is under User, and its related data lives next to
it in arrays:
{
"_id": "c6435c66-60f4-4476-86ad-11977b64e6b8",
"User": {
"_id": "c6435c66-60f4-4476-86ad-11977b64e6b8",
"UserName": "egor",
"NormalizedUserName": "EGOR",
"Email": "egor@example.com",
"NormalizedEmail": "EGOR@EXAMPLE.COM",
"EmailConfirmed": false,
"PasswordHash": "AQAAAAIAAYagAAAAE…",
"SecurityStamp": "XBUXK3CTK645PNULEAGCPPNGQ2NF5TXN",
"ConcurrencyStamp": "4dbb187d-7acd-4e60-9f7f-f0a519891934",
"PhoneNumber": null,
"PhoneNumberConfirmed": false,
"TwoFactorEnabled": false,
"LockoutEnd": null,
"LockoutEnabled": true,
"AccessFailedCount": 0
},
"Roles": ["586648da-889b-4b07-a03d-a0aa5e1d3e61"],
"Claims": [{ "ClaimType": "tier", "ClaimValue": "gold" }],
"Logins": [{ "LoginProvider": "Google", "ProviderKey": "1093", "ProviderDisplayName": "Google" }],
"Tokens": [{ "LoginProvider": "Google", "Name": "access_token", "Value": "ya29.a0" }],
"Passkeys": []
}
A role document has the same shape: { "_id", "Role": { … }, "Claims": [ … ] }. Roles of a user holds role ids.
The user's own properties are untouched by the store: whatever your TUser serializes to is stored under User.
Your own user and role types
Inherit from IdentityUser<TKey> / IdentityRole<TKey> as usual. Plain properties are tracked without any
configuration. Nested objects and collections need a mode, otherwise they are compared as a whole value and a change
made in place (user.Profile.City = "…") is not detected:
public class AppUser : IdentityUser<Guid>
{
public Profile Profile { get; set; } = new();
public List<string> Interests { get; set; } = [];
}
builder.Services
.AddIdentity<AppUser, IdentityRole<Guid>>()
.AddMongoStores(
options => options.Database = database,
model =>
{
model.Entity<AppUser>(e =>
{
e.Property(u => u.Profile).IsChild(); // tracked field by field
e.Property(u => u.Interests).IsSet(); // $push / $pull of single values
});
});
Now user.Profile.City = "Kazan"; await userManager.UpdateAsync(user); sends
$set: { "User.Profile.City": "Kazan" } and nothing else.
See the MongoTracker property modes for all options.
Instead of the callback you can register your own IdentityModelBuilder instance before AddMongoStores;
the stores use it and the Identity configuration is added to it:
var model = new IdentityModelBuilder();
model.Entity<AppUser>(e => e.Property(u => u.Profile).IsChild());
builder.Services.AddSingleton(model);
builder.Services.AddIdentity<AppUser, IdentityRole<Guid>>().AddMongoStores(options => options.Database = database);
Identifiers
Users and roles created without an identifier get one, like with a database-generated key: Guid and string
keys receive a new Guid, ObjectId keys a new ObjectId. Identifiers of other types must be set before
CreateAsync. FindByIdAsync accepts the string form of any of these keys.
Applications using Guid keys usually register a serializer for the representation they store:
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
Indexes
UserManager checks that a user name is free before creating a user, but two registrations running at the same time
can both pass that check. With options.CreateIndexes = true the stores create these indexes when the application
starts (through an IHostedService, so a generic host such as ASP.NET Core is required):
| Collection | Index | Purpose |
|---|---|---|
| Users | User.NormalizedUserName, unique |
Rejects the second of two concurrent registrations of one name |
| Users | User.NormalizedEmail |
Lookup by email (not unique: unique emails are optional in Identity) |
| Users | Logins.LoginProvider + Logins.ProviderKey |
Lookup by external login |
| Users | Roles |
Users in a role |
| Users | Claims.ClaimType + Claims.ClaimValue |
Users for a claim |
| Users | Passkeys.CredentialId, unique (.NET 10) |
Lookup by passkey |
| Roles | Role.NormalizedName, unique |
Rejects the second of two concurrent creations of one role |
It is disabled by default because a unique index cannot be created on a collection that already contains duplicates.
User-only mode, custom stores
Without roles, use AddIdentityCore:
builder.Services
.AddIdentityCore<AppUser>()
.AddMongoStores(options => options.Database = database);
The stores are registered in DI and can be replaced or extended — inherit from UserStore<TUser, TRole, TKey>,
UserOnlyStore<TUser, TKey> or RoleStore<TRole, TKey> and register your class:
builder.Services.AddScoped<IUserStore<AppUser>, AuditedUserStore>();
Requirements and limitations
- .NET 8, .NET 9 or .NET 10; passkeys (
IUserPasskeyStore) on .NET 10. MongoDB.Driver3.9 or later (through Incendia.MongoTracker).- The store methods that change claims, logins, tokens, roles or passkeys only stage the change; it is saved by
UpdateAsync, whichUserManagercalls right after them. This is how the EF Core stores handle claims, logins, tokens and roles; for passkeys it also makes the change and the new concurrency stamp a single update. CallUpdateAsyncyourself if you use the store interfaces directly. IdentityOptions.Stores.ProtectPersonalDatais not supported: the stores do not encrypt personal data. Enabling it makesUserManagerthrow at construction instead of storing the data unencrypted.- Deleting a role does not remove its id from the users that had it. Such ids are ignored by
GetRolesAsync,IsInRoleAsyncandGetUsersInRoleAsync.
Tests
The integration tests run UserManager and RoleManager against a real MongoDB (Testcontainers) and check both the
stored data and the exact updates sent for every operation, including concurrent requests.
License
MIT.
Feedback
Questions and issues: GitHub Issues.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Incendia.MongoTracker (>= 3.0.0)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Identity.Core (>= 10.0.12)
- Microsoft.Extensions.Identity.Stores (>= 10.0.12)
-
net8.0
- Incendia.MongoTracker (>= 3.0.0)
- Microsoft.Extensions.Hosting.Abstractions (>= 8.0.1)
- Microsoft.Extensions.Identity.Core (>= 8.0.31)
- Microsoft.Extensions.Identity.Stores (>= 8.0.31)
-
net9.0
- Incendia.MongoTracker (>= 3.0.0)
- Microsoft.Extensions.Hosting.Abstractions (>= 9.0.20)
- Microsoft.Extensions.Identity.Core (>= 9.0.20)
- Microsoft.Extensions.Identity.Stores (>= 9.0.20)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.