Themia.Modules.Identity
0.51.0
dotnet add package Themia.Modules.Identity --version 0.51.0
NuGet\Install-Package Themia.Modules.Identity -Version 0.51.0
<PackageReference Include="Themia.Modules.Identity" Version="0.51.0" />
<PackageVersion Include="Themia.Modules.Identity" Version="0.51.0" />
<PackageReference Include="Themia.Modules.Identity" />
paket add Themia.Modules.Identity --version 0.51.0
#r "nuget: Themia.Modules.Identity, 0.51.0"
#:package Themia.Modules.Identity@0.51.0
#addin nuget:?package=Themia.Modules.Identity&version=0.51.0
#tool nuget:?package=Themia.Modules.Identity&version=0.51.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.
Erasing an account (IIdentityAnonymiser)
IIdentityAnonymiser.AnonymiseAsync(userId) scrubs a closed account while keeping the user row, so foreign keys from
your tables still resolve. In one transaction it:
- rewrites the row into an inactive tombstone: user name
anonymised-{id:N}, no e-mail, phone or password hash, confirmations and two-factor off, a new security stamp. The row is not soft-deleted, soFindByIdAsyncstill finds it; - deletes the user's refresh tokens, one-time tokens, claims, external logins and OTP challenges, including challenges keyed only by the phone number the user held;
- keeps role memberships (they are not personal data).
It is idempotent (AlreadyAnonymised still removes any stray child rows). A tenant user can be anonymised only from
that tenant's scope and a platform user only from the platform scope; otherwise the outcome is NotFound. A background
job sets the scope with TenantContextAccessor.BeginScope(tenantId) first. Register an
IUserLifecycleHooks.OnBeforeAnonymiseAsync to veto (for example under a legal hold); the reason it returns must not
contain personal data. Afterwards OnUserMutatedAsync(userId, UserMutation.Anonymised) fires with the id only.
Not covered, so put them in your closure runbook: audit actor columns, notification logs, conversations (they have their
own erasure) and your own tables; OTP challenges with no user id for a phone the user never held (they expire by TTL).
The security-stamp cache is per process: with ValidateSecurityStampOnEachRequest on a multi-instance host an old access
token can stay valid for up to SecurityStampCacheDuration. A concurrent erasure of the same user is re-run once, so
both callers succeed.
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.
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.
StartAsyncawaitsIOtpCodeSenderonly 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/startandotp/verify. The library cannot see the caller. PassRequireRateLimitingtoMapIdentityOtpEndpoints, 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.
AddThemiaIdentityOtpLogintakes the values when it runs and validates them there; they are notIOptions, do not reload, and a bad or missingCodeHashKeystops the application at startup. - Phone uniqueness. A phone shared by two users in scope resolves to whichever
IUserService.FindByPhoneAsyncreturns; 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
MaxFailedAttemptsPerWindowguesses 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
OnBeforeLoginAsyncfor now.
Requiring a second factor (staff MFA)
Themia does not verify a second factor; it gives the app one place to say "this user owes one" and one way to finish the
login afterwards. Register an ISecondFactorPolicy; the default asks nobody, so nothing changes until you do.
public sealed class StaffMfaPolicy(IMfaStore mfa) : ISecondFactorPolicy
{
public async ValueTask<SecondFactorRequirement> EvaluateAsync(SecondFactorContext context, CancellationToken ct = default) =>
!await mfa.IsStaffAsync(context.UserId, ct) ? SecondFactorRequirement.NotRequired
: await mfa.HasFactorAsync(context.UserId, ct) ? SecondFactorRequirement.Verify
: SecondFactorRequirement.Enrol;
}
services.AddSingleton<ISecondFactorPolicy, StaffMfaPolicy>(); // after AddThemiaIdentityAspNetCore (TryAdd)
- The policy runs after the primary check succeeded and before anything is issued, in all three Themia flows: password
(
LoginAsync), one-time code (VerifyAsync) and external login (AuthenticateAsync).SecondFactorContextcarries the method and, for external login, the provider key. - A login that owes a second factor returns
LoginOutcome.SecondFactorRequired(external:ExternalLoginOutcome.SecondFactorRequired) withresult.SecondFactor: theUserId, theStage(VerifyorEnrol), an opaque single-useTicketand itsExpiresAt(Verify tickets live 5 minutes, Enrol tickets 15). Nothing is issued: no refresh row, no tokens. It is not a failed login: no failure hook, no lockout count, andOnLoginSucceededfires only when the login completes. The built-in endpoints answer it with their uniform 401 because they cannot complete the factor; use your own controller. - The app verifies (or enrols and confirms) the factor itself, for example with
Themia.Totp, then callsISecondFactorCompletion.CompleteAsync(ticket, userId, stage). It consumes the ticket, repeats the original flow's gates in the ticket's tenant scope (the user still exists in that tenant, is active and not locked out, has the same security stamp, the login method is still switched on, a one-time-code user still has a confirmed phone), runs the method's own success hook and observer (external logins get the external ones, withWasCreated/WasLinked), and issues the session exactly as a normal login would (same claims, samesid). A hook that denies at completion returnsDenied. The single-use claim is one conditionalUPDATE(the first of two parallel completions wins, the other is refused). After too many wrong codes callRejectAsync(ticket)to burn the ticket; the attempt limits are the app's. - A policy that throws stops the login with that exception and nothing is issued (it is not turned into a "bad password").
A policy that asks for a second factor while no
ISecondFactorTicketServiceis registered also throws. - Pass the user and stage you just verified.
CompleteAsyncrefuses (and burns) a ticket that is not for exactly that user and stage. TakeuserIdfrom the account whose factor you checked, never from the request body: otherwise someone holding a victim's ticket could pass their own factor and receive the victim's session, or spend a verify ticket at an enrol endpoint. Your endpoint should look the pending login up by your own record of it (keyed by the ticket you handed out), not by a client-supplied user id. - Call
CompleteAsynconly after your own factor check has passed: completion trusts that the app verified (or enrolled and confirmed) the factor; Themia cannot tell. The ticket proves the primary check passed, not that the second factor did. - Enrol is only as strong as the primary check. Return
Enrolonly for accounts that have no factor yet, and notify the user (and any existing devices) when a factor is enrolled, otherwise whoever holds a stolen password can enrol their own. - Tickets are protected with ASP.NET Core data protection plus a single-use user token (
TokenPurpose.SecondFactorTicket); persist and protect the data-protection keys in production (a key store that is not encrypted at rest is a risk for every ticket and cookie). Losing them only invalidates tickets in flight. The ticket and the password are never logged. - The seam covers only the three Themia flows. Code that mints sessions with the public building blocks
(
IRefreshTokenService.IssueAsync,IAccessTokenService.Issue) bypasses it and must callISecondFactorPolicyitself. A federation or bot sign-in in your app is such a path. - Enabling a policy does not end existing sessions. Refresh families minted before the policy was on stay valid and keep
rotating; revoke the affected users' families (
IRefreshTokenService.RevokeAllForUserAsync) when you turn it on. - Breaking for source:
LoginOutcomeandExternalLoginOutcomegain a member, so an exhaustiveswitchwithout a discard stops compiling until it handlesSecondFactorRequired.result.Succeededis unchanged, soif (!result.Succeeded)callers fail closed.
Minting a session yourself (IAuthTokenIssuer)
Some sign-ins do not go through the Themia flows: a federation redirect, a chat-provider login, a renewal for a session you
already hold. Do not call IRefreshTokenService/IAccessTokenService directly for them: that skips the sid claim, the active and
lockout checks, the tenant check and the second-factor policy. Use IAuthTokenIssuer:
var request = new SessionRequest(tenantId, "federation", AppClaims: [new Claim("app:session_start", startUnix)]);
var r = await issuer.StartSessionAsync(userId, request, ct);
switch (r.Outcome)
{
case IssueOutcome.Issued: return Ok(r.Tokens); // r.SessionId is the sid
case IssueOutcome.SecondFactorRequired: return Accepted(r.SecondFactor!.Value); // verify the factor, then:
default: return Unauthorized(); // Inactive, LockedOut, TenantMismatch
}
// after YOUR factor check passed for exactly this user:
var done = await issuer.CompleteSessionAsync(userId, ticket, SecondFactorStage.Verify, request, ct); // the stage you handled
// a new access token for a session you already hold (a cookie renewal, say):
var renewed = await issuer.RenewSessionAsync(userId, sessionId, request, ct); // access token only; r.Tokens.RefreshToken is empty
- Pass the user id, not a loaded
User: the issuer reloads the user inExpectedTenanton every call, so a user locked or deactivated after you read them is refused (Inactive/LockedOut). A user outside that tenant, an unknown id and a platform user while platform login is off all answerTenantMismatch(it does not say which). - The second-factor policy runs for a new session with
SecondFactorMethod.Customand your channel label asProvider. Key a staff-MFA policy on the user, role or tenant, never on the channel label: a typo in a free label would be a bypass. An app-channel ticket is finished only throughCompleteSessionAsync(ISecondFactorCompletionrefuses it), which checks the user, channel, tenant and security stamp again and keeps your claims; it fires no credential-login hook. - A renewal skips the policy, so it must name a live session of this very user: not revoked, not expired, same tenant. Otherwise
SessionNotLive. After enabling a policy for existing users,IRefreshTokenService.RevokeAllForUserAsynctherefore ends their old sessions for good: renewals of those families are refused too. AppClaimsreplace claims of the same type already on the principal (carry a session-start claim forward without duplicating it). A reserved type (sid,sub,role,tenant_id,themia:*, ...) throwsArgumentExceptionbefore anything is issued. The channel must be 1 to 64 characters ofa-z 0-9 . _ -.- The refresh token is issued first (its family is the session) and revoked again, even if the request was cancelled, when the access
token cannot be issued.
IIdentityEventObserver.OnSessionIssuedAsyncis raised for a new session (not a renewal); an observer that throws does not fail it. - A renewal does not look at the security stamp, like a refresh: rotating the stamp (a password or phone change) does not end a session by
itself. End the user's sessions explicitly when that matters (
RevokeAllForUserAsync, orRevokeAllForUserExceptAsyncto keep the caller's), and renewals of those families are then refused (SessionNotLive). AppClaimsare the app's to compute on the server from its own data, never copied from a request: Themia only refuses the reserved types, it cannot tell a legitimate app claim from a forged one.- Not covered: principals that are not
Userrows (a service or bot token) stay in the app. - A custom
IRefreshTokenServicemust implementIsSessionLiveAsyncforRenewSessionAsyncto work; the default answers false (fail closed).
Sessions follow the security stamp
A refresh session is bound to the user's security stamp at the moment it was issued (identity.refresh_tokens.security_stamp,
migration 202610310002, nullable). When the stamp has changed, the next refresh is refused (Invalid) and the family is
revoked; IRefreshTokenService.IsSessionLiveAsync (and so IAuthTokenIssuer.RenewSessionAsync) is false.
- Ends sessions: a password reset (
SetPasswordAsync) and a deactivate or reactivate (SetActiveAsync). You no longer need to rememberRevokeAllForUserAsyncafter these; an app that already calls it keeps working. - Keeps sessions: a role or role-claim change rotates the stamp (to cut stale access tokens) but rebinds the user's live sessions to the new stamp, because refresh rebuilds the principal from the database. Only sessions bound to the stamp the user held just before the rotation follow it, so a session that predates a password reset is not revived by a later role edit. A re-hash of the password at login (a hasher cost bump) no longer rotates the stamp at all.
- Change password, keep this device: call
SetPasswordAsyncfirst, THENISessionRevoker.RevokeAllForUserExceptAsync(userId, sid). The kept session is rebound to the new stamp; in the reverse order it ends at its next refresh.sidmust be the authenticated principal's session id, never unauthenticated input: the method checks the family is the user's and live, not that it is the caller's. - Upgrade: the migration binds the live sessions open at that moment to their user's current stamp, so the first credential event after
the upgrade ends them. A row written later by a node that has not been upgraded yet stays unbound (NULL): it is accepted once and bound to
the stamp current at its next refresh, so during a rolling deploy keep
RevokeAllForUserAsyncafter a password reset until every node runs this version. - A role or claim change that loses a race with another stamp change still rotates the stamp (it swaps from whatever is current), so an access token minted after the other change is cut too. Only a user who keeps losing the race through three swaps keeps the other writer's stamp: their sessions end at the next refresh and an access token minted in that window keeps its old roles until it expires.
- A refresh replayed inside
RefreshReuseGraceafter such a change revokes the family and returnsInvalid(notReuseDetected, which is the theft path).
Refresh reuse grace (parallel refreshes)
A web app that refreshes per request with no shared memory can present the same refresh token twice within milliseconds (two tabs, parallel loads, polling). Without a grace the second request either loses the rotation race or arrives after it and counts as token theft: the whole family is revoked and the user is signed out everywhere. Opt in to a grace:
services.AddThemiaIdentityCore(o => o.RefreshReuseGrace = TimeSpan.FromSeconds(30)); // default Zero = off; at most 2 minutes
- Within the grace after a rotation, presenting the just-rotated token again returns the same successor the first request already
got (
RefreshOutcome.ReplayedWithinGrace;TryGetSuccessis true, the access token is issued fresh with the samesid). Nothing is rotated again and nothing is revoked.IIdentityEventObserver.OnRefreshReplayedAsyncis raised in addition toOnRefreshSucceededAsync. A request that loses the rotation race itself takes the same path and gets the winner's successor. - Everything else still revokes the family: a replay after the grace, a replay once the successor has itself been rotated again, a token whose family or successor was revoked or expired, a deactivated user, and any token rotated before the grace was switched on.
- The trade-off: inside the window a captured token and the user's own token yield the same successor, so the reuse tripwire does not
fire for that window. It is bounded by the 2-minute cap and off by default; 30 seconds covers a BFF fan-out.
RevokeAllForUserAsyncstill ends a family at once, and a family revoked during the window is not replayable. The shared successor is an ordinary token, so the two holders cannot both keep going: the first to rotate it makes the other's copy a consumed token, and presenting that revokes the family. A replay count would break the fan-out the grace exists for (several tabs at once), so there is none; the window is the limit. - How it works, and what it stores: while the grace is on, the rotated row keeps
successor_ciphertext: the successor's raw token sealed with AES-GCM under a key derived (HKDF-SHA256) from the rotated token's own raw value, with the row id as associated data. Only a holder of that raw value can read it, and the raw value is never stored, so a database copy alone yields nothing. There is no server secret to provision or rotate. The column is cleared by a family revoke (reuse detected, logout of one session) and by the next rotation of any of the user's tokens once the grace has passed, and is never read after the grace. A revoke-all or a rotated successor leaves it in place but unusable (a revoked or consumed successor is refused); a deployment that switches the grace off later keeps old values, which cannot be opened without the rotated token's raw value. - Schema: one nullable column
identity.refresh_tokens.successor_ciphertext(migration202610310001, PostgreSQL and SQL Server like the rest of Identity; MySQL is not supported by this module). It stays empty for a deployment that never enables the grace. - The window is measured with the clock of the node that wrote the rotation; with several nodes keep their clocks in sync (or keep the grace short).
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,
OnRefreshSucceededAsyncand the observers run in the owner's tenant. A token only ever acts as its own user.LogoutAsyncreturnsLogoutOutcome.Revokedonly when a token was revoked; thelogoutendpoint answers 204 for that and 401 otherwise (unknown or already-revoked token). OnBeforeRefreshAsyncsees 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.
IOtpRecipientResolverfinds 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:SetPhoneNumberAsyncthen refuses a number an active user in another tenant (or the platform) holds, with refusal codeIdentityModuleOptions.PhoneNumberInAnotherTenantRefusalCode. It only blocks new assignments; numbers already shared are untouched. - The tenant switch needs the accessor-backed
ITenantContext. Identity switches the ambient tenant throughTenantContextAccessor. EF Core's runtime tenant filter and Dapper behind theITenantContextthatAddThemiaAspNetCoreregisters both read it, so they follow the switch. A customITenantContextthat 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 acrossThemia.Framework.Core,Themia.Modules.IdentityandThemia.Modules.Identity.AspNetCorethroughInternalsVisibleTo, so install those at the same version (all Themia packages share one). - The cross-tenant phone option is not atomic.
RejectPhoneNumberHeldInAnotherTenantreads, 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.
OnLogoutAsyncand the logout observers run in the token owner's tenant scope (viaIRefreshTokenService.FindOwnerAsync); a customIRefreshTokenServicethat does not implement it keeps the caller's scope. - Mutation hooks.
OnUserMutatedAsyncand the observers run in the mutated user's tenant, so a hook that callsIRefreshTokenService.RevokeAllForUserAsync(userId)reaches the user's tokens whoever made the change.RevokeAllForUserAsyncitself 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*(...)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 |
LockoutPolicy |
ResetCountOnLock |
ResetCountOnLock zeroes AccessFailedCount at lock, so each expired lock buys MaxFailedAccessAttempts fresh guesses. KeepCount keeps it, so the first wrong password after expiry re-locks. Progressive is KeepCount plus a lock that doubles per consecutive lock up to MaxLockoutDuration; a successful login restarts it. Progressive lets someone who knows an account name keep it locked for up to the cap. Under KeepCount and Progressive only a successful login or a password reset (SetPasswordAsync) clears the count and the lock; there is no unlock API yet |
MaxLockoutDuration |
24 hours | Longest single lock under Progressive; must be between LockoutDuration and 365 days. Ignored otherwise |
ValidateSecurityStampOnEachRequest |
false |
When on, the Themia JwtBearer scheme refuses a token whose stamp claim differs from the user's stored stamp, or whose user is inactive or deleted. Password change, disable and role change rotate the stamp; delete removes the user. Tokens without the claim are refused once on |
SecurityStampCacheDuration |
30 seconds | How long a per-user stamp lookup is cached in process; bounds how long a revoked token keeps working. TimeSpan.Zero disables the cache |
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.51.0)
- Themia.Framework.Data.Abstractions (>= 0.51.0)
- Themia.Modules.Identity.Abstractions (>= 0.51.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.51.0 | 0 | 10/31/2026 |
| 0.50.0 | 51 | 10/31/2026 |
| 0.49.0 | 53 | 10/31/2026 |
| 0.48.0 | 61 | 10/30/2026 |
| 0.47.0 | 52 | 10/30/2026 |
| 0.46.0 | 42 | 10/30/2026 |
| 0.45.1 | 58 | 10/29/2026 |
| 0.45.0 | 77 | 10/29/2026 |
| 0.44.0 | 89 | 10/28/2026 |
| 0.43.0 | 63 | 10/28/2026 |
| 0.42.1 | 127 | 10/27/2026 |
| 0.42.0 | 101 | 10/27/2026 |
| 0.41.0 | 155 | 10/25/2026 |
| 0.40.0 | 114 | 10/25/2026 |
| 0.39.0 | 129 | 10/25/2026 |
| 0.38.0 | 121 | 10/24/2026 |
| 0.37.0 | 164 | 10/24/2026 |
| 0.36.0 | 150 | 10/23/2026 |
| 0.35.3 | 125 | 10/22/2026 |
| 0.35.2 | 137 | 10/20/2026 |