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 8,800 lines of code across 195 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 /tokens/refresh. - ASP.NET Identity on top of
FshUser : IdentityUserandFshRole : 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. - 48 endpoints under
/api/v1/identity/...covering tokens, users, roles, groups, sessions, impersonation, and 2FA. Rate-limitedauthpolicy applied to login, refresh, confirm-email, forgot-password, reset-password, and self-register. - Outbox-backed integration events —
UserRegisteredIntegrationEventandTokenGeneratedIntegrationEventgo out via the Eventing module’s outbox dispatcher.
Architecture at a glance
src/Modules/Identity/├── Modules.Identity/ ~8,800 LoC, 195 files│ ├── IdentityModule.cs IModule entry — order 100│ ├── Domain/│ │ ├── FshUser.cs IdentityUser + IHasDomainEvents│ │ ├── FshRole.cs IdentityRole + Description│ │ ├── Group.cs IAuditable, ISoftDeletable│ │ ├── ImpersonationGrant.cs IGlobalEntity (cross-tenant)│ │ ├── UserSession.cs IHasDomainEvents│ │ └── PasswordHistory.cs│ ├── Data/IdentityDbContext.cs MultiTenantIdentityDbContext│ ├── Features/v1/ 48 features in 11 areas│ │ ├── Tokens/ Generate, Refresh│ │ ├── Users/ Register + 14 mgmt features│ │ ├── Roles/ 6 features│ │ ├── 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/ ~1,000 LoC public commands/queries/servicesThe 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/tokens/generate. 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/TokenGeneration/
public sealed record GenerateTokenCommand( string Email, string Password, string? TwoFactorCode = null) : ICommand<TokenResponse>;
public sealed record TokenResponse( string AccessToken, string RefreshToken, int ExpiresInSeconds, string TokenType = "Bearer");
public sealed record RefreshTokenCommand( 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 permission set. Permission enforcement on every other 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
48 endpoints means a lot of contracts. Major ones:
Tokens
| Type | Purpose |
|---|---|
GenerateTokenCommand(email, password, twoFactorCode?) | Login |
RefreshTokenCommand(refreshToken) | Rotate the refresh token, mint a new access token |
Users
| Type | Purpose |
|---|---|
RegisterUserCommand | Operator-driven user creation; requires Users.Create |
SelfRegisterUserCommand | Anonymous self-registration (rate-limited) |
ConfirmEmailCommand(userId, token) | Email confirmation step |
ForgotPasswordCommand(email) → token | Issue reset token |
ResetPasswordCommand(email, token, password) | Apply reset; respects history |
ChangePasswordCommand(userId, old, new) | Self-service change |
ToggleUserStatusCommand(userId, isActive) | Activate / deactivate user |
AssignUserRolesCommand(userId, roleIds[]) | Replace user’s role set |
SetProfileImageCommand(userId, imageUrl) | Profile image |
GetMeQuery() | Current user with permissions |
Roles
| Type | Purpose |
|---|---|
UpsertRoleCommand(name, description) | Create or rename |
UpdateRolePermissionsCommand(roleId, permissions[]) | Set the role’s permission set |
GetRolePermissionsQuery(roleId) | Fetch current permissions |
Sessions
GetMySessionsQuery, RevokeSessionCommand, RevokeAllSessionsCommand, plus admin variants (GetTenantSessionsQuery, AdminRevokeSessionCommand) for ops.
Impersonation
StartImpersonationCommand(impersonatedUserId, durationMinutes, reason) returns an impersonation access token. EndImpersonationCommand ends the active grant. RevokeImpersonationGrantCommand(grantId, reason) is the kill switch for admins.
Two-Factor
EnrollTwoFactorCommand returns a QR code + secret; VerifyEnrollTwoFactorCommand(code) activates; DisableTwoFactorCommand turns it off.
Endpoints
All 48 endpoints are under /api/v1/identity/. The rate-limited auth policy covers POST /tokens/generate, POST /tokens/refresh, POST /users/forgot-password, POST /users/reset-password, POST /users/confirm-email, and POST /users/self-register. Full table:
| Verb | Route | What it does |
|---|---|---|
| POST | /tokens | Login |
| POST | /tokens/refresh | Rotate refresh token |
| GET | /users/me | Current user profile |
| POST | /users/register | Operator-driven user creation |
| POST | /users/self-register | Anonymous registration |
| POST | /users/forgot-password | Issue password-reset token |
| POST | /users/reset-password | Reset using token |
| POST | /users/confirm-email | Confirm email address |
| POST | /users/{id}/change-password | Change password |
| GET | /users | List users |
| POST | /users/search | Search users |
| GET | /users/{id} | Get user by id |
| PUT | /users/{id} | Update user |
| DELETE | /users/{id} | Delete user |
| PATCH | /users/{id}/status | Activate / deactivate |
| POST | /users/{id}/roles | Assign roles |
| GET | /users/{id}/roles | List user roles |
| GET | /users/{id}/groups | List user groups |
| GET | /users/me/permissions | Current user permissions |
| POST | /users/{id}/profile-image | Set profile image |
| GET | /roles | List roles |
| GET | /roles/{id} | Get role |
| POST,PUT | /roles | Upsert role |
| DELETE | /roles/{id} | Delete role |
| GET | /roles/{id}/permissions | Get role permissions |
| PUT | /roles/{id}/permissions | Update role permissions |
| GET | /groups | List groups |
| GET | /groups/{id} | Get group |
| POST | /groups | Create group |
| PUT | /groups/{id} | Update group |
| DELETE | /groups/{id} | Delete group |
| GET | /groups/{id}/members | List members |
| POST | /groups/{id}/members | Add members |
| DELETE | /groups/{id}/members/{userId} | Remove member |
| GET | /sessions/me | My sessions |
| DELETE | /sessions/{id} | Revoke a session |
| DELETE | /sessions/me | Revoke all my sessions |
| GET | /sessions/tenant | Tenant sessions (admin) |
| GET | /sessions/user/{userId} | User sessions (admin) |
| DELETE | /sessions/user/{userId}/{id} | Admin revoke one session |
| DELETE | /sessions/user/{userId} | Admin revoke all user sessions |
| POST | /impersonation/start | Start impersonation |
| POST | /impersonation/end | End impersonation |
| GET | /impersonation/grants | List grants |
| DELETE | /impersonation/grants/{id} | Revoke grant |
| POST | /2fa/enroll | Enroll 2FA |
| POST | /2fa/verify | Verify enrolment |
| DELETE | /2fa | Disable 2FA |
Configuration
{ "PasswordPolicy": { "PasswordHistoryCount": 5, // prevent reuse of the last N passwords "PasswordExpiryDays": 90, // force change cadence "PasswordExpiryWarningDays": 14, "EnforcePasswordExpiry": true }, "Jwt": { "Issuer": "fullstackhero", "Audience": "fullstackhero-api", "AccessTokenMinutes": 60, "RefreshTokenDays": 14, "SigningKey": "set via secrets manager or env var" }}Lockout is configured by IdentityOptions and defaults to 5 failed attempts → 15-minute lock. The identity-outbox-dispatcher Hangfire job runs every minute to dispatch outbox events.
How to extend
Add a new permission
- Pick a stable resource + action pair (e.g.
Users,Export). - Add a constant to
IdentityPermissionsin the Contracts assembly. - Register it through
PermissionConstants.Register([...])during module startup (Identity already does this; mirror the pattern). - Apply it on the endpoint:
.RequirePermission(IdentityPermissions.Users.Export). - Seed roles with the permission via
UpdateRolePermissionsCommandor 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.
Swap email confirmation for an OTP / magic link
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/(33 test files) cover handlers, password policy, JWT options. - Integration tests at
src/Tests/Integration.Tests/Tests/Impersonation/cover the cross-tenant impersonation flow.
Related
- Security — overall security posture beyond Identity.
- Multitenancy module — tenant resolution + per-tenant connection strings.
- Architecture: dependency injection — how the module loader wires
ICurrentUserand permissions.