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 : 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. - Permission catalog —
GET /permissions/catalogreturns 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-limitedauthpolicy applied to login, refresh, confirm-email, resend-confirmation-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/ ~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/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/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
| Type | Purpose |
|---|---|
GenerateTokenCommand(email, password, twoFactorCode?) | Login |
RefreshTokenCommand(token?, refreshToken) | Rotate the refresh token, mint a new access token |
Users
| Type | Purpose |
|---|---|
RegisterUserCommand | Operator-driven user creation; also reused by anonymous /self-register (rate-limited) |
ConfirmEmailCommand(userId, code, tenant) | Email confirmation step (link-friendly GET) |
ForgotPasswordCommand(email) → token | Issue 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
| Type | Purpose |
|---|---|
UpsertRoleCommand(id, name, description?) | Create or rename |
UpdatePermissionsCommand | Set 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:
| Verb | Route | What it does |
|---|---|---|
| POST | /token/issue | Login |
| POST | /token/refresh | Rotate refresh token |
| GET | /profile | Current user profile |
| PUT | /profile | Update own profile |
| PUT | /profile/image | Set / clear profile image |
| GET | /permissions | Current user permissions |
| GET | /permissions/catalog | All registered permissions (catalog) |
| POST | /register | Operator-driven user creation |
| POST | /self-register | Anonymous registration |
| POST | /forgot-password | Issue password-reset token |
| POST | /reset-password | Reset using token |
| GET | /confirm-email | Confirm email (?userId=&code=&tenant=) |
| POST | /change-password | Change own password |
| GET | /users | List users |
| GET | /users/search | Search users |
| GET | /users/{id} | Get user by id |
| DELETE | /users/{id} | Delete user |
| PATCH | /users/{id} | Activate / deactivate |
| POST | /users/{id}/confirm-email | Admin: confirm a user’s email |
| POST | /users/{id}/resend-confirmation-email | Resend confirmation email |
| POST | /users/{id}/roles | Assign roles |
| GET | /users/{id}/roles | List user roles |
| GET | /users/{userId}/groups | List user groups |
| GET | /roles | List roles |
| GET | /roles/{id} | Get role |
| POST | /roles | Upsert role (create or update) |
| DELETE | /roles/{id} | Delete role |
| GET | /{id}/permissions | Get role with permissions |
| PUT | /{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/{groupId}/members | List members |
| POST | /groups/{groupId}/members | Add members |
| DELETE | /groups/{groupId}/members/{userId} | Remove member |
| GET | /sessions/me | My sessions |
| DELETE | /sessions/{sessionId} | Revoke a session |
| POST | /sessions/revoke-all | Revoke all my sessions (optionally keep one) |
| GET | /sessions | Tenant sessions (admin, paged) |
| GET | /users/{userId}/sessions | User sessions (admin) |
| DELETE | /users/{userId}/sessions/{sessionId} | Admin revoke one session |
| POST | /users/{userId}/sessions/revoke-all | Admin revoke all user sessions |
| POST | /impersonation/start | Start impersonation |
| POST | /impersonation/end | End impersonation |
| GET | /impersonation/grants | List grants |
| POST | /impersonation/grants/{id}/revoke | Revoke grant |
| POST | /2fa/enroll | Enroll 2FA |
| POST | /2fa/verify | Verify enrolment |
| POST | /2fa/disable | Disable 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
{ "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
- 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
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.
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/(30 test files) cover handlers, password policy, JWT options. - Integration tests at
src/Tests/Integration.Tests/Tests/— theImpersonation/,Sessions/,Users/,Roles/,Groups/,Authentication/, andAuthorization/folders all exercise this module end to end.
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.