Themia.Modules.Identity 0.35.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package Themia.Modules.Identity --version 0.35.0
                    
NuGet\Install-Package Themia.Modules.Identity -Version 0.35.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Themia.Modules.Identity" Version="0.35.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Themia.Modules.Identity" Version="0.35.0" />
                    
Directory.Packages.props
<PackageReference Include="Themia.Modules.Identity" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Themia.Modules.Identity --version 0.35.0
                    
#r "nuget: Themia.Modules.Identity, 0.35.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Themia.Modules.Identity@0.35.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Themia.Modules.Identity&version=0.35.0
                    
Install as a Cake Addin
#tool nuget:?package=Themia.Modules.Identity&version=0.35.0
                    
Install as a Cake Tool

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.Dapper AddThemiaIdentityDapper / IdentityDapperModule
EF Core Themia.Modules.Identity.EFCore AddThemiaIdentityEFCore / IdentityEFCoreModule

Upgrading from 0.12.x? AddThemiaIdentityServices and IdentityModule are 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: ThemiaDbContext stamps created_by/modified_by from its protected virtual string? CurrentUserId property (defaults to null), not from ICurrentUserAccessor. To record the real user you must override CurrentUserId in 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*. IdentityDapperModule contributes 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, and ICurrentUser in 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 LinkedToAnotherUser and writes nothing. Two concurrent links of the same identity to different users are settled by the database's unique index: exactly one Linked.
  • 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.OnBeforeLinkExternalLoginAsync overload that carries currentLogins.
  • 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.

Passwordless phone-code login

IOtpLoginFlow signs a user in with a short numeric code sent to their confirmed phone, and issues the same tokens as IAuthenticationFlow.LoginAsync, so refresh, logout and your hooks and observers work unchanged. It is opt in.

services.AddThemiaIdentityAspNetCore(/* jwt */);
services.AddScoped<IOtpCodeSender, SmsCodeSender>();          // yours: delivery stays in the application
services.AddThemiaIdentityOtpLogin(o =>
{
    o.CodeHashKey = Convert.FromBase64String(config["Otp:HashKey"]!);   // required, at least 32 random bytes
});

app.MapGroup("/auth").MapIdentityAuthEndpoints()
   .MapIdentityOtpEndpoints(b => b.RequireRateLimiting("otp"));         // POST otp/start, POST otp/verify

StartAsync(phone) returns a ChallengeId and ExpiresAt, the same shape whether or not the phone has an account (an unknown, unconfirmed, inactive or locked-out phone gets a decoy challenge and no message). VerifyAsync(challengeId, code) returns the usual LoginResult: InvalidCredentials for a wrong, expired, used or unknown code, LockedOut once the attempt cap is hit (hooks and observers see LoginFailureReason.OtpAttemptsExceeded, not LockedOut, which means the account is locked), Denied when a hook refuses. The HTTP endpoints answer every failure with the same 401, and 429 when a start is throttled.

Option Default
CodeHashKey none, required HMAC key for the stored code hash, at least 32 random bytes. You supply it (user secrets, key vault, environment); Themia generates and stores nothing. Every instance must get the same key. Never logged. Rotating it ends the codes in flight.
CodeLength 6 6 to 9 digits.
Lifetime 5 min
MaxAttempts 5 Guesses per challenge.
MaxFailedAttemptsPerWindow 10 Failed guesses per phone across all challenges inside FailureWindow. A new start never resets it.
FailureWindow 1 h Rolling window for the failure cap and the start cap.
ResendCooldown 60 s Minimum time between two starts for one phone.
MaxStartsPerWindow 5 Starts per phone inside FailureWindow.

What is guaranteed: the code is random (RandomNumberGenerator), stored only as an HMAC and compared in constant time. A guess is counted and the code consumed in one conditional UPDATE, so parallel guesses cannot exceed the cap and a code cannot log in twice. Only the newest challenge for a phone is open. After the code is right, VerifyAsync re-checks that the user is active, not locked out and still has a confirmed phone, runs OnBeforeLoginAsync (identifier is the normalized phone) and OnLoginSucceededAsync, then issues tokens. The OTP failure counter is separate from AccessFailedCount, so guessing codes cannot lock a user out of password login.

Known limits, so you can decide what to add:

  • Delivery latency. StartAsync awaits IOtpCodeSender only for a phone that can log in, so a slow gateway makes that response slower than the unknown-phone case. Implement the sender to enqueue and return quickly. The start and failure throttles bound how often this can be probed.
  • The host app must add a per-IP rate limiter on otp/start and otp/verify. The library cannot see the caller. Pass RequireRateLimiting to MapIdentityOtpEndpoints, or limit in your own endpoints. Without it one client can spread starts over many phone numbers and run up SMS cost.
  • The phone budget can be used against its owner. Anyone who knows a number can spend its start and failure budget (5 starts, 10 failed guesses per window) and so block that person's one-time-code login until the window slides. Password login is not affected. This is the price of a cap that does not reset per challenge; the per-IP limiter above is what makes it expensive to do at scale.
  • Options are read once, at registration. AddThemiaIdentityOtpLogin takes the values when it runs and validates them there; they are not IOptions, do not reload, and a bad or missing CodeHashKey stops the application at startup.
  • Phone uniqueness. A phone shared by two users in scope resolves to whichever IUserService.FindByPhoneAsync returns; keep phone numbers unique per tenant (the module's normalized-phone index does).
  • Throttles are keyed on the phone across tenants. A platform user is reachable from any tenant context, so tenants that share a number share its start and failure budget.
  • A successful login clears the phone's recent failure count, so a user who logs in regularly gives a guesser fresh budget; the per-challenge cap and the start cap still bound it (at most MaxFailedAttemptsPerWindow guesses of 10^6 per success).
  • Parallel starts are re-checked after the insert, so a burst cannot all send; racing starts may all be refused and the user retries.
  • Expired rows are purged in small batches on later starts; there is no background job.
  • Which login methods are enabled per tenant or user is not modelled here. Enforce it in OnBeforeLoginAsync for now.

Refresh, logout and one-time-code login with no tenant scope

With a claims-only tenant strategy the tenant comes from the JWT, but refresh, logout, otp/start and otp/verify carry no JWT, so their ambient tenant is null. Identity handles that without trusting the caller: the proof is a secret, and the tenant is read from the row the secret proves.

  • Refresh / logout. The refresh-token hash (256 random bits) finds the token; its owner is that row's own user, in whatever tenant it lives. Rotation, the lockout check, the claims principal, OnRefreshSucceededAsync and the observers run in the owner's tenant. A token only ever acts as its own user. LogoutAsync returns LogoutOutcome.Revoked only when a token was revoked; the logout endpoint answers 204 for that and 401 otherwise (unknown or already-revoked token).
  • OnBeforeRefreshAsync sees a null scope. It runs before the token is looked up, so no owner is known yet. Do not read "no tenant" as "platform user" or as "allowed" in that hook; make the decision after the owner is known (OnRefreshSucceededAsync).
  • OTP start. IOtpRecipientResolver finds the holder of the phone across tenants. Exactly one active user with a confirmed phone and no lockout is a recipient; none, or several (the same number active in two tenants), is a decoy with the same response and no message. If the host does set an ambient tenant, only that tenant and platform users are candidates.
  • OTP verify. The challenge is found by its id (a random GUID) in any tenant, the guess is counted and the code consumed there, and the login runs in the challenge's tenant. The user must belong to that same tenant.
  • Same number in two tenants. With no tenant hint such a number cannot receive a code. To stop it arising, set IdentityModuleOptions.RejectPhoneNumberHeldInAnotherTenant = true: SetPhoneNumberAsync then refuses a number an active user in another tenant (or the platform) holds, with refusal code IdentityModuleOptions.PhoneNumberInAnotherTenantRefusalCode. It only blocks new assignments; numbers already shared are untouched.
  • The tenant switch needs the accessor-backed ITenantContext. Identity switches the ambient tenant through TenantContextAccessor. EF Core's runtime tenant filter and Dapper behind the ITenantContext that AddThemiaAspNetCore registers both read it, so they follow the switch. A custom ITenantContext that returns its own fixed value is not switched, and a claims-only host using one would still see a null scope. The switch is internal and shared across Themia.Framework.Core, Themia.Modules.Identity and Themia.Modules.Identity.AspNetCore through InternalsVisibleTo, so install those at the same version (all Themia packages share one).
  • The cross-tenant phone option is not atomic. RejectPhoneNumberHeldInAnotherTenant reads, then writes: two tenants setting the same number at the same instant can both pass, and no portable constraint can express "unique across tenants among active users". The one-time-code login fails closed on any duplicate that gets through (a decoy for every holder).
  • Logout hooks. OnLogoutAsync and the logout observers run in the token owner's tenant scope (via IRefreshTokenService.FindOwnerAsync); a custom IRefreshTokenService that does not implement it keeps the caller's scope.
  • Mutation hooks. OnUserMutatedAsync and the observers run in the mutated user's tenant, so a hook that calls IRefreshTokenService.RevokeAllForUserAsync(userId) reaches the user's tokens whoever made the change. RevokeAllForUserAsync itself stays caller-scoped (a tenant's code cannot revoke another tenant's sessions by id) and logs a warning when it revokes nothing because the user is not visible.

Notes / gotchas

  • Dapper: register the data peer first. Call AddThemiaDapper*(...) before AddThemiaIdentityDapper or IdentityDapperModule. The identity entity mappings go into the EntityMappingRegistry that 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 unqualified users; it now throws at registration. (EF adopters are unaffected — see ApplyThemiaIdentity() above.)
  • AddThemiaIdentityAuthorization() replaces ICurrentUserAccessor. It calls RemoveAll<ICurrentUserAccessor>() and registers IdentityCurrentUserAccessor, so any previously-registered custom ICurrentUserAccessor is 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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.35.1 0 10/17/2026
0.35.0 0 10/17/2026
0.34.0 0 10/17/2026
0.33.0 33 10/17/2026
0.32.0 49 10/15/2026
0.31.3 141 10/14/2026
0.31.2 94 10/13/2026
0.31.1 105 10/13/2026
0.31.0 88 10/13/2026
0.30.5 114 10/13/2026
0.30.4 271 10/4/2026
0.30.3 122 10/4/2026
0.30.2 155 10/3/2026
0.30.1 190 9/27/2026
0.30.0 140 9/27/2026
0.29.0 183 9/19/2026
0.28.1 153 9/19/2026
0.28.0 133 9/19/2026
0.27.0 140 9/19/2026
0.26.0 222 9/15/2026
Loading failed