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 catalogGET /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 eventsUserRegisteredIntegrationEvent 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.

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
PUT/profileUpdate own profile
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.