Themia.Modules.Identity
0.29.0
dotnet add package Themia.Modules.Identity --version 0.29.0
NuGet\Install-Package Themia.Modules.Identity -Version 0.29.0
<PackageReference Include="Themia.Modules.Identity" Version="0.29.0" />
<PackageVersion Include="Themia.Modules.Identity" Version="0.29.0" />
<PackageReference Include="Themia.Modules.Identity" />
paket add Themia.Modules.Identity --version 0.29.0
#r "nuget: Themia.Modules.Identity, 0.29.0"
#:package Themia.Modules.Identity@0.29.0
#addin nuget:?package=Themia.Modules.Identity&version=0.29.0
#tool nuget:?package=Themia.Modules.Identity&version=0.29.0
Themia.Modules.Identity
Tenant-aware Identity core for Themia applications. Provides user/role/claim storage, argon2id
password hashing, account lifecycle tokens (email/phone confirmation, password reset, 2FA flag),
lockout, the ICurrentUser principal, and ASP.NET Core authorization integration.
Supports both data peers — EF Core and Dapper — over a single FluentMigrator schema (PostgreSQL and SQL Server).
This package is the engine-agnostic core. It carries no data peer, no database driver and no migration runner. Reference it plus exactly one engine package:
Your data layer Add this package Register with Dapper Themia.Modules.Identity.DapperAddThemiaIdentityDapper/IdentityDapperModuleEF Core Themia.Modules.Identity.EFCoreAddThemiaIdentityEFCore/IdentityEFCoreModuleUpgrading from 0.12.x?
AddThemiaIdentityServicesandIdentityModuleare gone — see MIGRATION.md.
Quick start
1. Register a data peer
Pick one of the following depending on your data layer.
EF Core — PostgreSQL
builder.Services.AddThemiaPostgres<AppDbContext>(builder.Configuration);
EF Core — SQL Server
builder.Services.AddThemiaSqlServer<AppDbContext>(builder.Configuration);
Dapper — PostgreSQL
builder.Services.AddThemiaDapperPostgres(builder.Configuration);
Dapper — SQL Server
builder.Services.AddThemiaDapperSqlServer(builder.Configuration);
2. Configure your DbContext (EF Core only)
Derive from ThemiaDbContext and call modelBuilder.ApplyThemiaIdentity() in OnModelCreating.
Important — EF audit stamping:
ThemiaDbContextstampscreated_by/modified_byfrom itsprotected virtual string? CurrentUserIdproperty (defaults tonull), not fromICurrentUserAccessor. To record the real user you must overrideCurrentUserIdin your context:
using Themia.Framework.Data.EFCore;
using Themia.Framework.Core.Abstractions.Security;
using Themia.Modules.Identity.EntityConfiguration; // from Themia.Modules.Identity.EFCore
public sealed class AppDbContext(DbContextOptions options, ICurrentUserAccessor currentUser)
: ThemiaDbContext(options)
{
protected override string? CurrentUserId => currentUser.UserId;
protected override void OnModelCreating(ModelBuilder b)
{
b.ApplyThemiaIdentity();
base.OnModelCreating(b);
}
}
The Dapper peer reads ICurrentUserAccessor directly, so no additional override is needed there.
3. Register the Identity module
Use the module from the engine package matching the peer you registered in step 1. The
MigrationEngine argument is the database and is orthogonal to the peer — both are explicit.
using Themia.Data.Migrations;
using Themia.Modules.Identity.Dapper; // or Themia.Modules.Identity.EFCore
// Inside your IThemiaBuilder / host setup, AFTER the data peer registration:
builder.AddModule(new IdentityDapperModule(MigrationEngine.Postgres));
// or
builder.AddModule(new IdentityEFCoreModule(MigrationEngine.SqlServer));
Dapper: the module must be configured after
AddThemiaDapper*.IdentityDapperModulecontributes the identity entity mappings to the registry that call creates, and throws if it does not exist yet. A host whose module loop runs first fails to start, with the ordering named in the message.
The module automatically:
- Runs the FluentMigrator identity schema migration on startup.
- Registers
IUserService,IRoleService,IClaimService,IUserTokenService,IPasswordHasher,IClaimsPrincipalFactory, andICurrentUserin the DI container. - Wires the engine-specific store: the Dapper mappings, or (EF Core) a startup check that
ApplyThemiaIdentity()was actually applied to the context Themia resolves.
Prefer plain DI? Call the engine's extension method instead, again after the peer:
builder.Services.AddThemiaDapperPostgres(builder.Configuration);
builder.Services.AddThemiaIdentityDapper(o => o.AllowPlatformLogin = true);
builder.Services.AddThemiaIdentityAuthorization();
The module already calls AddThemiaIdentityAuthorization(), so you normally don't need to. It registers
IHttpContextAccessor, the ICurrentUser principal, and overrides the audit-user accessor
(ICurrentUserAccessor) so it reads the authenticated user from the HTTP context. It does not register
any authorization policies.
Supplying your own repositories
AddThemiaIdentityCore registers the services with no data peer at all — for an application providing its
own IRepository<T, TKey> implementations. It also applies no schema: this package carries the
FluentMigrator migration classes but no runner, because running them needs a driver for each engine and the
core stays driver-free. Run them yourself:
ThemiaMigrations.Run(MigrationEngine.Postgres, connectionString, IdentityMigrations.Assembly);
4. Use the services
public class AccountController(IUserService users, ICurrentUser currentUser) : ControllerBase
{
[HttpPost("register")]
public async Task<IActionResult> Register(RegisterDto dto, CancellationToken ct)
{
var result = await users.CreateAsync(dto.UserName, dto.Password, dto.Email, ct);
if (!result.Succeeded)
return BadRequest(result.Error);
return Ok(new { result.UserId });
}
}
Inject any of:
| Interface | Purpose |
|---|---|
IUserService |
Create, find, delete, set-active, change password, verify password |
IUserLifecycleHooks |
Refuse or observe changes to a user's credential state (see below) |
IRoleService |
Create roles, assign/remove users from roles |
IClaimService |
Add/remove user and role claims, resolve effective claims |
IUserTokenService |
Generate and consume one-time tokens (email confirm, password reset, etc.) |
IExternalLoginLinkService |
Link, unlink, list and look up the external identities on an existing user (see below) |
ICurrentUser |
Read the authenticated principal (UserId, TenantId, Roles, Claims) |
Refusing and observing user mutations
IUserLifecycleHooks lets your app veto a change to a user's credential state, and see the ones that
went through. IAuthenticationHooks covers the login lifecycle only; a rule keyed on credential state —
"this account must keep one usable way to sign in", "you cannot remove the last administrator", "this
user still owns open invoices" — could otherwise only be enforced by owning every call site.
Every mutation has a hook, not a chosen few. A seam covering three of seven paths reads as covering all seven. Every method has a default implementation, so override only what you care about:
internal sealed class LockoutGuard(AppDbContext db) : IUserLifecycleHooks
{
public async ValueTask<UserMutationDecision> OnBeforeSetPhoneNumberAsync(
Guid userId, string? phoneNumber, CancellationToken ct = default)
{
// Setting a number clears its confirmation, so this is the path that can lock an
// SMS-only account out of its own sign-in.
if (phoneNumber is null && await db.IsPhoneOnlyAsync(userId, ct))
return UserMutationDecision.Refuse("This is the only way you can sign in.");
return UserMutationDecision.Allow();
}
public ValueTask OnUserMutatedAsync(Guid userId, UserMutation mutation, CancellationToken ct = default)
=> auditTrail.RecordAsync(userId, mutation, ct);
}
// Register BEFORE AddThemiaIdentity* — the module's permissive default is registered with TryAdd.
services.AddScoped<IUserLifecycleHooks, LockoutGuard>();
A refusal returns UserMutationOutcome.Refused carrying your reason, and nothing is written:
var result = await users.SetPhoneNumberAsync(userId, null, ct);
return result.Outcome switch
{
UserMutationOutcome.Success => NoContent(),
UserMutationOutcome.Refused => Conflict(result.Reason),
UserMutationOutcome.Duplicate => Conflict("That number is already in use."),
UserMutationOutcome.UserNotFound => NotFound(),
_ => throw new UnreachableException(),
};
With more than one rule on a mutation, refuse with a code — UserMutationDecision.Refuse(reason, code) —
and branch on the result's RefusalCode, never on the wording of Reason. Every result that can be
Refused carries it.
Transaction contract. A before-hook runs inside the caller's scope, before the module touches any
entity and before its unit of work opens. It must not call SaveChanges and must not open a
transaction on the same scoped connection — the module saves immediately after the hook returns, so a
hook holding a transaction there turns a refusal into a deadlock. Read freely; write through your own
connection if you must write at all. OnUserMutatedAsync runs after the save: the change is already
committed, and throwing does not undo it.
Linking external identities to an existing user
IExternalLoginService.ResolveOrProvisionAsync is the sign-in path: it resolves an identity, and opens an
account when it cannot. IExternalLoginLinkService is everything else — for a user who is already signed
in and wants a second channel, and for code that needs to ask "who owns this identity?" without creating
anyone.
// The user is signed in (via LINE) and has just proved control of a Telegram identity.
var result = await linking.LinkAsync(userId, telegramIdentity, ct);
return result.Outcome switch
{
ExternalLoginLinkOutcome.Linked or
ExternalLoginLinkOutcome.AlreadyLinkedToUser => await ReissueSessionAsync(userId, ct),
ExternalLoginLinkOutcome.LinkedToAnotherUser => Conflict("channel_identity_already_linked"),
ExternalLoginLinkOutcome.UserInactive => Forbid(),
ExternalLoginLinkOutcome.Refused => Conflict(result.Reason),
ExternalLoginLinkOutcome.UserNotFound => Unauthorized(),
_ => throw new UnreachableException(),
};
// "Does anyone own this identity?" — never provisions.
var owner = await linking.FindUserByLoginAsync("telegram", subject, ct);
// A notification job resolving addresses for a batch of recipients: one call, not one per row.
var byUser = await linking.GetLoginsForUsersAsync(recipientIds, ct); // at most 1,000 distinct ids
Four rules worth knowing before you build on it:
- An identity is never moved between users. Linking one another user holds returns
LinkedToAnotherUserand writes nothing. Two concurrent links of the same identity to different users are settled by the database's unique index: exactly oneLinked. - A deactivated or locked-out user is never linked (
UserInactive) — the same rule the sign-in path's auto-link follows, so a reactivation cannot inherit a login nobody approved. - A user may hold several identities from one provider. Two Google accounts is legitimate, so the
module does not refuse a second. If your product allows one per provider, refuse it in the
IUserLifecycleHooks.OnBeforeLinkExternalLoginAsyncoverload that carriescurrentLogins. - Linking and unlinking end no sessions. Sessions are not tagged by the identity that opened them, so
after unlinking a compromised identity call
IRefreshTokenService.RevokeAllForUserAsync(userId)— it is the only way to reach the session that identity holds.
Unlinking is how a user without a password locks themselves out, and the module does not stop it: a
sign-in method can live entirely outside Identity (a phone-OTP login on Themia.Challenges, for one), so
"the last way in" is something only your code can compute. Put that rule in
IUserLifecycleHooks.OnBeforeUnlinkExternalLoginAsync.
Both link hooks have an overload that receives the user's current links, because a hook cannot read them
itself — IExternalLoginLinkService is the one calling it, so injecting it is a DI cycle. The unlink
overload gets every provider's links, not only the one being removed:
internal sealed class ChannelRules : IUserLifecycleHooks
{
public ValueTask<UserMutationDecision> OnBeforeLinkExternalLoginAsync(
Guid userId, string provider, string subject, IReadOnlyList<ExternalLoginInfo> currentLogins,
CancellationToken ct = default) =>
ValueTask.FromResult(currentLogins.Any(l => l.Provider == provider)
? UserMutationDecision.Refuse("You already linked this channel.", "channel_already_linked")
: UserMutationDecision.Allow());
public ValueTask<UserMutationDecision> OnBeforeUnlinkExternalLoginAsync(
Guid userId, string provider, IReadOnlyList<ExternalLoginInfo> currentLogins,
CancellationToken ct = default) =>
ValueTask.FromResult(currentLogins.All(l => l.Provider == provider)
? UserMutationDecision.Refuse("This is your only channel.", "last_channel")
: UserMutationDecision.Allow());
}
The list is read before the write, not locked: two concurrent links for one user each see the other's absent, so a rule that must hold under concurrency needs a constraint of its own.
Notes / gotchas
- Dapper: register the data peer first. Call
AddThemiaDapper*(...)beforeAddThemiaIdentityDapperorIdentityDapperModule. The identity entity mappings go into theEntityMappingRegistrythat the peer registration creates; registering Identity first means there is no registry to contribute to. That used to be skipped silently and surface much later as a query against unqualifiedusers; it now throws at registration. (EF adopters are unaffected — seeApplyThemiaIdentity()above.) AddThemiaIdentityAuthorization()replacesICurrentUserAccessor. It callsRemoveAll<ICurrentUserAccessor>()and registersIdentityCurrentUserAccessor, so any previously-registered customICurrentUserAccessoris replaced. This is intentional — Identity becomes the audit-user source — but an adopter with a custom accessor should be aware it will not survive. (Both engine modules call this automatically.)
Platform users
A platform user is a user whose tenant_id IS NULL in the database. Platform users can
authenticate across all tenants when IdentityModuleOptions.AllowPlatformLogin = true (the
default).
// Check at runtime:
if (currentUser.IsPlatform) { /* platform-level operation */ }
Extending the user profile (1:1 table pattern)
Themia's User entity holds identity data only. Add app-specific profile fields in your own table
with a foreign key to user_id:
public class UserProfile
{
public Guid UserId { get; set; } // FK → identity.users.id
public string? DisplayName { get; set; }
public string? AvatarUrl { get; set; }
}
Configure it in your AppDbContext.OnModelCreating. Themia never touches this table.
Options
IdentityModuleOptions (configurable via the IdentityDapperModule(engine, options) /
IdentityEFCoreModule(engine, options) overload, or the AddThemiaIdentity* lambda):
| Property | Default | Description |
|---|---|---|
MaxFailedAccessAttempts |
5 | Consecutive failures before lockout |
LockoutDuration |
15 minutes | How long an account stays locked |
DefaultTokenLifetime |
1 hour | Expiry for generated tokens |
AllowPlatformLogin |
true |
Whether platform users (tenant_id IS NULL) can log in |
ConnectionStringName |
"Default" |
Connection string key used by Dapper |
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | 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
- FluentMigrator (>= 8.0.1)
- Konscious.Security.Cryptography.Argon2 (>= 1.3.1)
- Themia.Framework.Core (>= 0.29.0)
- Themia.Framework.Data.Abstractions (>= 0.29.0)
- Themia.Modules.Identity.Abstractions (>= 0.29.0)
NuGet packages (3)
Showing the top 3 NuGet packages that depend on Themia.Modules.Identity:
| Package | Downloads |
|---|---|
|
Themia.Modules.Identity.AspNetCore
JWT bearer authentication wiring for the Themia Identity module — token issuance, validation, and ASP.NET Core middleware integration. |
|
|
Themia.Modules.Identity.Dapper
Dapper store wiring for Themia.Modules.Identity — contributes the Identity entity mappings to the Dapper EntityMappingRegistry and runs the identity schema migration. Reference this instead of the core when your data peer is Dapper. |
|
|
Themia.Modules.Identity.EFCore
EF Core store wiring for Themia.Modules.Identity — the ModelBuilder configuration for the identity entities and the identity schema migration runner. Reference this instead of the core when your data peer is EF Core. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.29.0 | 91 | 9/19/2026 |
| 0.28.1 | 109 | 9/19/2026 |
| 0.28.0 | 81 | 9/19/2026 |
| 0.27.0 | 95 | 9/19/2026 |
| 0.26.0 | 206 | 9/15/2026 |
| 0.25.1 | 228 | 9/11/2026 |
| 0.25.0 | 123 | 9/10/2026 |
| 0.24.0 | 129 | 9/10/2026 |
| 0.23.1 | 326 | 9/7/2026 |
| 0.23.0 | 140 | 9/7/2026 |
| 0.22.1 | 126 | 9/6/2026 |
| 0.22.0 | 119 | 9/5/2026 |
| 0.21.4 | 271 | 9/4/2026 |
| 0.21.3 | 170 | 8/30/2026 |
| 0.21.2 | 122 | 8/29/2026 |
| 0.21.1 | 139 | 8/29/2026 |
| 0.21.0 | 124 | 8/29/2026 |
| 0.20.0 | 117 | 8/27/2026 |
| 0.19.0 | 115 | 8/27/2026 |
| 0.18.0 | 119 | 8/27/2026 |