Skip to content
fullstackhero

Reference

Identity module

JWT bearer + refresh tokens, ASP.NET Identity with roles + permissions, user groups, operator impersonation, two-factor TOTP, sessions, and password-policy enforcement.

views 0 Last updated

The Identity module is fullstackhero’s load-bearing auth layer. It wraps ASP.NET Identity with JWT bearer + refresh, a flat-but-granular permission system enforced via .RequirePermission() on endpoints, user groups, operator impersonation with a full audit trail, two-factor TOTP, per-device sessions, password history, and an outbox-backed integration event publisher for things like user registration and password changes. Around 10,400 lines of code across 205 files, it’s the most substantial module in the kit.

What ships in v10

  • JWT bearer + refresh tokens with a rotating refresh on each POST /token/refresh.
  • ASP.NET Identity on top of FshUser : IdentityUser and FshRole : IdentityRole - custom password policy, 5-attempt lockout for 15 minutes, custom claims, passkey table.
  • Permission-based authorization via a flat registry (PermissionConstants) and the .RequirePermission(...) fluent helper on endpoints.
  • User groups - organise users; groups own roles (GroupRoles) and propagate permissions to members.
  • Operator impersonation - a SuperAdmin can issue a time-bound impersonation grant against another user (cross-tenant supported via IGlobalEntity), with revocation list and an audit trail.
  • Two-factor TOTP - enrol with a QR code, verify, disable per user. Optional, not enforced.
  • Sessions - per-device records with IP, user-agent, OS, browser, and revocation; cleanup runs as a hosted service.
  • Password policy - history (default 5), expiry (default 90 days) with warning window (14 days), enforced via IPasswordHistoryService + IPasswordExpiryService.
  • Permission catalog - GET /permissions/catalog returns every permission registered with the host, filtered to the caller’s tenant context (root vs admin set). The admin app’s role editor is built on it.
  • 51 endpoints under /api/v1/identity/... covering tokens, users, roles, the permission catalog, groups, sessions, impersonation, and 2FA. Rate-limited auth policy applied to login, refresh, confirm-email, resend-confirmation-email, forgot-password, reset-password, and self-register.
  • Outbox-backed integration events - UserRegisteredIntegrationEvent and TokenGeneratedIntegrationEvent go out via the Eventing module’s outbox dispatcher.

Architecture at a glance

src/Modules/Identity/
├── Modules.Identity/ ~10,400 LoC, 205 files
│ ├── IdentityModule.cs IModule entry - order 100
│ ├── Domain/
│ │ ├── FshUser.cs IdentityUser + IHasDomainEvents
│ │ ├── FshRole.cs IdentityRole + Description
│ │ ├── Group.cs, GroupRole.cs, UserGroup.cs Groups + group-owned roles
│ │ ├── ImpersonationGrant.cs IGlobalEntity (cross-tenant)
│ │ ├── UserSession.cs IHasDomainEvents
│ │ └── PasswordHistory.cs
│ ├── Data/IdentityDbContext.cs MultiTenantIdentityDbContext
│ ├── Features/v1/ 51 endpoints in 8 areas
│ │ ├── Tokens/ Generate, Refresh
│ │ ├── Users/ Register + 19 more features
│ │ ├── Roles/ 6 features
│ │ ├── Permissions/ Permission catalog
│ │ ├── Groups/ 8 features
│ │ ├── Sessions/ 7 features
│ │ ├── Impersonation/ 4 features
│ │ └── TwoFactor/ 3 features
│ └── Services/ Split by responsibility
│ ├── ITokenService IssueAsync, IssueAccessOnlyAsync
│ ├── ICurrentUserService ICurrentUser implementation
│ ├── IUserRegistrationService User creation
│ ├── IUserPasswordService Change/reset/expiry checks
│ ├── IUserPermissionService Role/group permission aggregation
│ ├── ISessionService Session CRUD + cleanup
│ └── IImpersonationGrantService Grant + revoke
└── Modules.Identity.Contracts/ Public commands/queries/services

The module loads at order 100 - the first module - so every later module can rely on ICurrentUser, the JWT pipeline, and the permission registry being already in place.

The token pipeline

Login goes through POST /api/v1/identity/token/issue. The handler validates email/password through UserManager, checks 2FA if enrolled, mints an access token + refresh token via ITokenService, opens a UserSession, and publishes a TokenGeneratedIntegrationEvent to the outbox.

// src/Modules/Identity/Modules.Identity.Contracts/v1/Tokens/
public record GenerateTokenCommand(
string Email,
string Password,
string? TwoFactorCode = null)
: ICommand<TokenResponse>;
public sealed record TokenResponse(
string AccessToken,
string RefreshToken,
DateTime RefreshTokenExpiresAt,
DateTime AccessTokenExpiresAt);
public record RefreshTokenCommand(string? Token, string RefreshToken)
: ICommand<RefreshTokenCommandResponse>;

The access token carries the standard claims plus tenant (resolved via Finbuckle on the request that issued it) and the user’s roles (direct + group-derived). Permissions themselves stay server-side - they’re resolved from the role set on each authorization check, so a permission change takes effect without re-issuing tokens. Enforcement on an endpoint is just:

endpoints.MapPost("/users", handler)
.RequirePermission(IdentityPermissions.Users.Create);

PathAwareAuthorizationHandler lets you mix anonymous endpoints (the login itself, forgot-password, confirm-email) with permission-gated ones without separate policies per endpoint.

Public API highlights

51 endpoints means a lot of contracts. Major ones:

Tokens

TypePurpose
GenerateTokenCommand(email, password, twoFactorCode?)Login
RefreshTokenCommand(token?, refreshToken)Rotate the refresh token, mint a new access token

Users

TypePurpose
RegisterUserCommandOperator-driven user creation; also reused by anonymous /self-register (rate-limited)
ConfirmEmailCommand(userId, code, tenant)Email confirmation step (link-friendly GET)
ForgotPasswordCommand(email) → tokenIssue reset token
ResetPasswordCommand(email, password, token)Apply reset; respects history
ChangePasswordCommand(password, newPassword, confirmNewPassword)Self-service change
ToggleUserStatusCommand(userId, activateUser)Activate / deactivate user
AssignUserRolesCommand(userId, userRoles[])Replace user’s role set
SetProfileImageCommand(imageUrl?)Profile image (null clears it)
GetCurrentUserProfileQuery(userId)Current user profile

Roles

TypePurpose
UpsertRoleCommand(id, name, description?)Create or rename
UpdatePermissionsCommandSet the role’s permission set
GetRoleWithPermissionsQuery(id)Fetch a role with its current permissions

Sessions

GetMySessionsQuery, RevokeSessionCommand, RevokeAllSessionsCommand(exceptSessionId?), plus admin variants (GetTenantSessionsQuery, GetUserSessionsQuery, AdminRevokeSessionCommand, AdminRevokeAllSessionsCommand) for ops.

Impersonation

StartImpersonationCommand(targetUserId, targetTenantId, reason?, durationMinutes?) returns an impersonation access token (duration capped server-side at 60 minutes). EndImpersonationCommand ends the active grant and returns a fresh operator token. RevokeImpersonationGrantCommand(grantId, reason?) is the kill switch for admins.

Two-Factor

EnrollTwoFactorCommand returns a QR code + secret; VerifyEnrollTwoFactorCommand(code) activates; DisableTwoFactorCommand(currentPassword) turns it off.

Profile concurrency

PUT /profile replaces the whole representation, so two overlapping saves used to silently overwrite each other: whoever wrote last won, and the other person’s change was gone with no error anywhere. The endpoint now supports an optimistic precondition.

GET /profile returns the user’s ConcurrencyStamp as a strong ETag. ASP.NET Identity already treats that column as an EF Core concurrency token and rotates it on every write, so no extra column and no migration were needed. Echo the tag back as If-Match on the PUT and the write is rejected with 412 Precondition Failed when the stored profile has moved on:

GET /api/v1/identity/profile
→ 200 OK
ETag: "6f9619ff-8b86-d011-b42d-00cf4fc964ff"
PUT /api/v1/identity/profile
If-Match: "6f9619ff-8b86-d011-b42d-00cf4fc964ff"
→ 200 OK # nobody else wrote in between
→ 412 # someone did; nothing was written

Details worth knowing:

  • The header is optional. A PUT without If-Match behaves exactly as before, so existing callers keep working. If-Match: * is accepted for the same reason: it matches any current representation.

  • Weak validators never match. If-Match mandates the strong comparison function, so W/"..." is answered with 412.

  • A malformed header is a 400, not a 412. A 412 would send a well-behaved client into a refetch-and-retry loop it can never win, because the broken header is its own bug.

  • Nothing is written or deleted before the check. The precondition runs ahead of the storage calls, so a rejected update never orphans an uploaded avatar and never clears the current one.

  • Do not auto-retry a 412. The obvious reflex - refetch the profile and resend against the fresh tag - performs exactly the overwrite the 412 rejected, because the only body the client holds is the one built from the values the user saw. Tell the user the profile changed, keep their edits on screen, adopt the current version, and let them save again deliberately. That is what clients/dashboard does.

  • Take the tag from the read that populated the form, not from a read issued inside the save. The lost update this guards against happens between the moment the user saw the values and the moment they save, so a tag fetched at save time is never stale and therefore never catches anything.

  • The token also rotates on writes the user does not think of as profile edits, such as a password change, a new avatar, or a failed sign-in. Those show up as a 412 on an otherwise innocent save, which is why the message the user sees should read as “this changed, review and save again” rather than as an error.

  • CORS is already configured for it. ETag is not a CORS-safelisted response header, so the framework’s CORS policy exposes it explicitly and if-match ships in CorsOptions.AllowedHeaders. Without both, a browser on another origin cannot read the tag and silently stops sending the precondition. Keep them in place if you replace the policy with your own.

Endpoints

All 51 endpoints are under /api/v1/identity/. The rate-limited auth policy covers POST /token/issue, POST /token/refresh, GET /confirm-email, POST /users/{id}/resend-confirmation-email, POST /forgot-password, POST /reset-password, and POST /self-register. Full table:

VerbRouteWhat it does
POST/token/issueLogin
POST/token/refreshRotate refresh token
GET/profileCurrent user profile (returns a strong ETag)
PUT/profileUpdate own profile (honours If-Match, answers 412 on a stale token)
PUT/profile/imageSet / clear profile image
GET/permissionsCurrent user permissions
GET/permissions/catalogAll registered permissions (catalog)
POST/registerOperator-driven user creation
POST/self-registerAnonymous registration
POST/forgot-passwordIssue password-reset token
POST/reset-passwordReset using token
GET/confirm-emailConfirm email (?userId=&code=&tenant=)
POST/change-passwordChange own password
GET/usersList users
GET/users/searchSearch users
GET/users/{id}Get user by id
DELETE/users/{id}Delete user
PATCH/users/{id}Activate / deactivate
POST/users/{id}/confirm-emailAdmin: confirm a user’s email
POST/users/{id}/resend-confirmation-emailResend confirmation email
POST/users/{id}/rolesAssign roles
GET/users/{id}/rolesList user roles
GET/users/{userId}/groupsList user groups
GET/rolesList roles
GET/roles/{id}Get role
POST/rolesUpsert role (create or update)
DELETE/roles/{id}Delete role
GET/{id}/permissionsGet role with permissions
PUT/{id}/permissionsUpdate role permissions
GET/groupsList groups
GET/groups/{id}Get group
POST/groupsCreate group
PUT/groups/{id}Update group
DELETE/groups/{id}Delete group
GET/groups/{groupId}/membersList members
POST/groups/{groupId}/membersAdd members
DELETE/groups/{groupId}/members/{userId}Remove member
GET/sessions/meMy sessions
DELETE/sessions/{sessionId}Revoke a session
POST/sessions/revoke-allRevoke all my sessions (optionally keep one)
GET/sessionsTenant sessions (admin, paged)
GET/users/{userId}/sessionsUser sessions (admin)
DELETE/users/{userId}/sessions/{sessionId}Admin revoke one session
POST/users/{userId}/sessions/revoke-allAdmin revoke all user sessions
POST/impersonation/startStart impersonation
POST/impersonation/endEnd impersonation
GET/impersonation/grantsList grants
POST/impersonation/grants/{id}/revokeRevoke grant
POST/2fa/enrollEnroll 2FA
POST/2fa/verifyVerify enrolment
POST/2fa/disableDisable 2FA (requires current password)

Yes, the role-permission routes really are /{id}/permissions directly under /api/v1/identity - there’s no /roles/ segment in them.

Configuration

appsettings.json
{
"PasswordPolicy": {
"PasswordHistoryCount": 5, // prevent reuse of the last N passwords
"PasswordExpiryDays": 90, // force change cadence
"PasswordExpiryWarningDays": 14,
"EnforcePasswordExpiry": true
},
"JwtOptions": {
"Issuer": "fsh.local",
"Audience": "fsh.clients",
"AccessTokenMinutes": 45,
"RefreshTokenDays": 7,
"SigningKey": "set via secrets manager or env var"
}
}

Lockout is configured by IdentityOptions and defaults to 5 failed attempts → 15-minute lock. Outbox events are dispatched by the framework’s OutboxDispatcherHostedService (on by default) - the module deliberately registers no dispatcher of its own, since a second one would race the same rows.

How to extend

Add a new permission

  1. Pick a stable resource + action pair (e.g. Users, Export).
  2. Add a constant to IdentityPermissions in the Contracts assembly.
  3. Register it through PermissionConstants.Register([...]) during module startup (Identity already does this; mirror the pattern).
  4. Apply it on the endpoint: .RequirePermission(IdentityPermissions.Users.Export).
  5. Seed roles with the permission via UpdatePermissionsCommand (PUT /{id}/permissions) or the role-permission seeder.

Add a custom claim to the JWT

Subclass TokenService (or wrap with a decorator) and add the claim during IssueAsync. The existing user metadata service is the canonical place to source the claim value from.

The ConfirmEmailCommand flow is a token exchange. Replace the token-generation step (currently UserManager.GenerateEmailConfirmationTokenAsync) and the redeem step (ConfirmEmailAsync) to issue and accept an OTP. The endpoint surface stays the same.

Tests

  • Unit tests at src/Tests/Identity.Tests/ (30 test files) cover handlers, password policy, JWT options.
  • Integration tests at src/Tests/Integration.Tests/Tests/ - the Impersonation/, Sessions/, Users/, Roles/, Groups/, Authentication/, and Authorization/ folders all exercise this module end to end.