Skip to content

Pragmatic.Identity

Identity and authentication stack for the Pragmatic.Design ecosystem. Five packages that layer from pure abstractions up to full database-backed authorization with temporal roles and JIT provisioning.

ASP.NET Core’s ClaimsPrincipal is a transport object, not a domain abstraction. Every service that needs the current user’s identity, permissions, or tenant repeats the same pattern: resolve IHttpContextAccessor, null-check HttpContext, navigate claims with magic strings, handle provider-specific claim type URIs. The claim parsing is scattered, stringly typed, and breaks when you switch identity providers. Background jobs have no HttpContext at all, so the entire pattern fails outside HTTP requests.

// This is what every service looks like without Pragmatic.Identity
var userId = principal?.FindFirst("sub")?.Value
?? principal?.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
var roles = principal?.FindAll("http://schemas.microsoft.com/ws/2008/06/identity/claims/role")
.Select(c => c.Value);

The problems compound:

  • Magic strings everywhere. Every service hardcodes claim type URIs. Change your identity provider and you hunt down every occurrence.
  • No background job support. IHttpContextAccessor returns null in hosted services, event handlers, and seed scripts. You need a separate abstraction that never existed.
  • Authorization is scattered. Permission checks are inline if statements. There is no unified way to resolve roles into permissions, cache the result, or apply wildcard matching.
  • Testing requires HTTP infrastructure. To test code that reads ClaimsPrincipal, you need to fabricate an HttpContext. Domain logic should not need HTTP plumbing.

Pragmatic.Identity replaces ClaimsPrincipal with a structured ICurrentUser interface that separates identity, authorization, and authentication into dedicated sub-objects. Claim type mapping is centralized and configurable. The same code works across HTTP requests (ClaimsPrincipalUserAccessor), background jobs (SystemUser), and tests (HeaderUserMiddleware) without modification.

// Type-safe, no magic strings, works everywhere
public class OrderService(ICurrentUser currentUser)
{
public async Task CreateOrder(CreateOrderRequest request)
{
var userId = currentUser.Id;
var tenantId = currentUser.TenantId;
if (!currentUser.Authorization.HasPermission("orders.create"))
return Result.Failure(new ForbiddenError());
}
}

Development and production authentication switch via configuration — the same codebase runs with HTTP headers (dev) or JWT tokens (production) based on whether a signing key is present in appsettings.json.

PackagePurposeASP.NET Core?EF Core?
Pragmatic.IdentityCore runtime: SystemUser, IdentityOptions, ClaimsPermissionCheckerNoNo
Pragmatic.Identity.AspNetCoreMaps ClaimsPrincipal to ICurrentUser, dev auth middlewareYesNo
Pragmatic.Identity.LocalSelf-hosted identity provider: register, login, password resetNoNo
Pragmatic.Identity.Local.JwtJWT token generation/validation for Local identityYesNo
Pragmatic.Identity.PersistenceEF Core stores for temporal roles, groups, JIT provisioningNoYes
Pragmatic.Abstractions (ICurrentUser, IUserAuthorization, PrincipalKind, AnonymousUser)
|
Pragmatic.Identity (SystemUser, IdentityOptions, ClaimsPermissionChecker, IdentityRecord)
|
+----+----+
| |
v v
Identity. Identity.Local (LocalIdentity, RegisterUser, LoginUser, ChangePassword,
AspNetCore RequestPasswordReset, ConfirmPasswordReset, BcryptPasswordHasher)
| |
+----+----+
|
v
Identity.Local.Jwt (JwtTokenGenerator, JwtOptions, UseJwtAuthentication)
Pragmatic.Authorization (CachedPermissionResolver, IPermissionProvider, IRolePermissionStore)
|
v
Identity.Persistence (EfRolePermissionStore, EfGroupRoleStore, JitProvisioningService,
IdentityUserBase, UserRole, UserGroup, RolePermission, GroupRole)

Development (header-based auth, no IdP required)

Section titled “Development (header-based auth, no IdP required)”
Program.cs
await PragmaticApp.RunAsync(args, app =>
{
app.UseAuthentication<NoOpAuthenticationHandler>("PragmaticDefault");
});

Requests authenticate via HTTP headers:

GET /api/reservations
X-User-Id: user-42
X-User-Name: Jane Doe
X-User-Roles: admin,booking-manager
X-User-Permissions: reservation.create,reservation.read
X-User-Tenant: tenant-1
X-User-Groups: customer-care
Program.cs
await PragmaticApp.RunAsync(args, app =>
{
app.UseJwtAuthentication(jwt =>
{
jwt.SigningKey = app.Configuration["Jwt:Key"]!;
jwt.Issuer = "https://myapp.example.com";
jwt.TokenExpiration = TimeSpan.FromHours(1);
});
});

Config-driven (dev or prod from appsettings)

Section titled “Config-driven (dev or prod from appsettings)”
await PragmaticApp.RunAsync(args, app =>
{
var jwtKey = app.Configuration["Jwt:Key"];
if (!string.IsNullOrEmpty(jwtKey))
{
app.UseJwtAuthentication(jwt =>
{
jwt.SigningKey = jwtKey;
jwt.Issuer = app.Configuration["Jwt:Issuer"];
});
}
else
{
app.UseAuthentication<NoOpAuthenticationHandler>("PragmaticDefault");
}
});

The ICurrentUser interface is the central identity abstraction. It is defined in Pragmatic.Abstractions and implemented by ClaimsPrincipalUserAccessor at runtime.

public interface ICurrentUser
{
string Id { get; } // User identifier (empty for anonymous)
string? DisplayName { get; } // Human-readable name
bool IsAuthenticated { get; } // Whether authenticated
PrincipalKind Kind { get; } // Anonymous | User | Service | System
string? TenantId { get; } // Multi-tenant identifier
string? ImpersonatedBy { get; } // Impersonator user ID (if applicable)
IReadOnlyDictionary<string, IReadOnlyList<string>> Claims { get; } // All claims, multi-valued
IUserAuthorization Authorization { get; } // Roles, permissions, groups, scopes
IAuthenticationContext Authentication { get; } // Scheme, issuer, MFA, expiration
}
ValueDescriptionExample
AnonymousNo authenticated identityUnauthenticated requests
UserHuman user via identity providerBrowser session, mobile app
ServiceMachine-to-machine callerAPI key, client credentials
SystemThe application itselfBackground jobs, data migrations
TypeIsAuthenticatedKindAuthorizationUse Case
AnonymousUserfalseAnonymousNullUserAuthorization (all denied)Fallback for unauthenticated requests
SystemUsertrueSystemFullAccessUserAuthorization (all granted)Background jobs, seed, migrations
ClaimsPrincipalUserAccessorfrom claimsUserCachedPermissionResolver (lazy)HTTP requests

Extension methods for common operations on ICurrentUser:

// Safely get nullable Id (null instead of empty for anonymous)
string? userId = currentUser.IdOrNull();
// Display-friendly name with fallback
string displayName = currentUser.DisplayNameOrId(); // "Jane Doe" or "user-42"
// Typed claim access
string? email = currentUser.GetClaim("email");
IReadOnlyList<string> scopes = currentUser.GetClaims("scope");
public interface IUserAuthorization
{
IReadOnlyCollection<string> Roles { get; }
IReadOnlySet<string> Permissions { get; } // Expanded via permission providers
IReadOnlyCollection<string> Groups { get; }
IReadOnlyCollection<string> Scopes { get; }
bool HasPermission(string permission); // Exact + wildcard match
bool HasAnyPermission(IEnumerable<string> permissions); // OR
bool HasAllPermissions(IEnumerable<string> permissions); // AND
bool IsInRole(string role);
bool IsInGroup(string group);
bool HasScope(string scope);
}
public interface IAuthenticationContext
{
string? Scheme { get; } // "Bearer", "Cookie"
string? Protocol { get; } // "oidc", "saml2", "apikey"
string? Issuer { get; } // Token issuer URI
string? Subject { get; } // Subject identifier
bool IsMfaAuthenticated { get; } // Multi-factor status
DateTimeOffset? AuthenticatedAt { get; }
DateTimeOffset? ExpiresAt { get; }
string? ExternalIdentityKey { get; } // "{issuer}|{subject}" for IdP correlation
}

The authorization pipeline resolves permissions through a provider chain:

ClaimsPrincipal (HTTP request)
|
v
ClaimsPrincipalUserAccessor (ICurrentUser)
|
v
CachedPermissionResolver (IUserAuthorization)
| Collects from all IPermissionProvider instances, ordered by .Order
|
+-- ClaimsPermissionProvider (Order=0, reads "permission" claims)
+-- RoleExpansionProvider (Order=100, role -> permissions via IRolePermissionStore)
+-- GroupExpansionProvider (Order=200, group -> roles -> permissions via IGroupRoleStore)
+-- [Custom providers...]
|
v
Merged permission set (HashSet<string>, case-insensitive)
|
v
WildcardMatcher for pattern checks (e.g., "booking.*" matches "booking.reservation.create")

Permission resolution is lazy (first access triggers resolution) and cached per request (or cross-request via ICacheStack using CacheCategories.Permissions when configured).

WildcardMatcher.Matches() supports glob-style patterns:

// Exact match
currentUser.Authorization.HasPermission("booking.reservation.create"); // true
// Wildcard permission in the user's set
// If user has "booking.*", then:
currentUser.Authorization.HasPermission("booking.reservation.create"); // true
currentUser.Authorization.HasPermission("billing.invoice.read"); // false

IdentityOptions controls which claim types are used to populate ICurrentUser properties. Override defaults when your identity provider uses non-standard claim types.

services.Configure<IdentityOptions>(options =>
{
// Standard claim types (defaults)
options.UserIdClaimType = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier";
options.DisplayNameClaimType = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name";
options.RoleClaimType = "http://schemas.microsoft.com/ws/2008/06/identity/claims/role";
options.PermissionClaimType = "permission";
options.TenantClaimType = "tenant_id";
});
PropertyDefaultDescription
UserIdClaimTypehttp://schemas.xmlsoap.org/.../nameidentifierClaim type for user ID
DisplayNameClaimTypehttp://schemas.xmlsoap.org/.../nameClaim type for display name
RoleClaimTypehttp://schemas.microsoft.com/.../roleClaim type for roles
PermissionClaimTypepermissionClaim type for permissions
TenantClaimTypetenant_idClaim type for tenant identifier

Common overrides for popular providers:

// Auth0
options.UserIdClaimType = "sub";
options.PermissionClaimType = "permissions";
// Azure AD / Entra ID
options.RoleClaimType = "roles";
options.TenantClaimType = "tid";
// Keycloak
options.UserIdClaimType = "sub";
options.RoleClaimType = "realm_access.roles";

  • SystemUser — singleton for background jobs with full access
  • IdentityOptions — configurable claim type mapping (user ID, name, role, permission, tenant)
  • IdentityRecord — abstract base for provider-specific identity data
  • ClaimsPermissionChecker — bridges ICurrentUser.Authorization to async IPermissionChecker
  • ProvisionSource — enum (Local, External, Jit) tracking how a user was provisioned
  • ClaimsPrincipalUserAccessor — resolves ICurrentUser from HttpContext.User
  • ClaimsAuthenticationContext — maps ClaimsPrincipal authentication properties to IAuthenticationContext
  • HeaderUserMiddleware — dev middleware that creates ClaimsPrincipal from HTTP headers
  • NoOpAuthenticationHandler — dev auth handler that trusts pre-set identities
  • PragmaticPermissionRequirement — ASP.NET Core authorization requirement
  • PragmaticPermissionHandler — bridges permission checks to IPermissionChecker
  • PermissionMode — enum controlling permission check behavior (All, Any)
  • AddPragmaticIdentity() — registers ICurrentUser, IPermissionChecker, authorization handler
  • UseAuthentication() / UseAuthentication<THandler>()IPragmaticBuilder extensions

Self-hosted identity actions that you deploy inside your own host — no external IdP dependency.

  • Actions: RegisterUser, LoginUser, ChangePassword, RequestPasswordReset, ConfirmPasswordReset
  • Services: IPasswordHasher / BcryptPasswordHasher, ISecurityTokenService / HmacSecurityTokenService, IPasswordPolicy / DefaultPasswordPolicy, ILocalIdentityStore (persistence abstraction)
  • Events: UserRegistered, UserLoggedIn, LoginFailed, AccountLocked, PasswordChanged, PasswordResetCompleted
  • Errors: InvalidCredentialsError, AccountLockedError, EmailAlreadyExistsError, InvalidResetTokenError, IdentityNotActiveError, PasswordPolicyError
  • Security: BCrypt password hashing, account lockout, brute-force protection, token-based password reset
  • Package: LocalIdentityPackage (IPackageDefinition) with route prefix identity/local
  • Permissions: LocalIdentityPermissions — SG-generated constants for identity actions
  • JwtTokenGenerator — creates JWT tokens with configurable claims (sub, name, tenant, roles, permissions)
  • JwtOptions — signing key, issuer, audience, expiration, clock skew
  • JwtLoginResult — token + expiration record
  • UseJwtAuthentication()IPragmaticBuilder extension for full JWT setup (generation + validation)
  • Entities: IdentityUserBase<TKey>, ExternalIdentityRecord<TKey>, UserRole<TKey>, UserGroup<TKey>, RolePermission, GroupRole — all temporal (ValidFrom/ValidTo)
  • EF Stores: EfRolePermissionStore (temporal role-to-permission), EfGroupRoleStore (temporal group-to-role)
  • JIT Provisioning: JitProvisioningService<TUser, TKey> — create/update users on first external login
  • DbContext: IdentityDbContext<TUser, TKey> with pre-configured entity mappings
  • Builder: IdentityPersistenceBuilder with EnableJitProvisioning(), SkipRolePermissionStore(), SkipGroupRoleStore()
  • Configurations: RolePermissionConfiguration, GroupRoleConfiguration, UserRoleConfiguration, UserGroupConfiguration, ExternalIdentityRecordConfiguration

The Pragmatic.Identity.Local package provides 5 domain actions for local password authentication:

ActionRouteDescription
RegisterUserPOST /identity/local/registerCreate new local identity with email + password
LoginUserPOST /identity/local/loginAuthenticate and return JWT token
ChangePasswordPOST /identity/local/change-passwordUpdate password (requires authentication)
RequestPasswordResetPOST /identity/local/request-resetGenerate reset token (sent via email)
ConfirmPasswordResetPOST /identity/local/confirm-resetComplete reset with token + new password

Configure the local identity provider behavior:

services.Configure<LocalIdentityOptions>(options =>
{
options.MinPasswordLength = 10; // default: 8
options.MaxFailedLoginAttempts = 3; // default: 5
options.LockoutDuration = TimeSpan.FromMinutes(30); // default: 15 min
options.ResetTokenExpiry = TimeSpan.FromHours(2); // default: 1 hour
options.PasswordWorkFactor = 12; // BCrypt cost factor, default: 12
options.RequireEmailVerification = true; // default: false
});
PropertyDefaultDescription
MinPasswordLength8Minimum password length
MaxFailedLoginAttempts5Failed attempts before lockout
LockoutDuration15 minDuration of account lockout
ResetTokenExpiry1 hourPassword reset token validity
PasswordWorkFactor12BCrypt work factor (higher = slower + more secure)
RequireEmailVerificationfalseBlock login until email is verified

The IPasswordPolicy interface allows custom password rules. The built-in DefaultPasswordPolicy checks MinPasswordLength:

// Register a custom policy
services.AddSingleton<IPasswordPolicy, StrongPasswordPolicy>();
public class StrongPasswordPolicy : IPasswordPolicy
{
public VoidResult<PasswordPolicyError> Validate(string password)
{
if (password.Length < 12)
return new PasswordPolicyError("Password must be at least 12 characters.");
if (!password.Any(char.IsUpper))
return new PasswordPolicyError("Password must contain an uppercase letter.");
if (!password.Any(char.IsDigit))
return new PasswordPolicyError("Password must contain a digit.");
return VoidResult<PasswordPolicyError>.Success();
}
}

Each identity action raises domain events that you can handle for audit logging, notifications, or analytics:

[DomainAction]
public class SendWelcomeEmail : IDomainEventHandler<UserRegistered>
{
public async Task HandleAsync(UserRegistered @event, CancellationToken ct)
{
// Send welcome email to @event.Email
}
}
[DomainAction]
public class AuditLoginFailure : IDomainEventHandler<LoginFailed>
{
public async Task HandleAsync(LoginFailed @event, CancellationToken ct)
{
// Log failed attempt for @event.Email from @event.IpAddress
}
}

All role and permission assignments in Identity.Persistence are temporal — they have ValidFrom and ValidTo dates. This enables:

  • Scheduled role grants: “This contractor has admin access from March 1 to March 31.”
  • Automatic expiration: No cleanup jobs needed; the query filter handles it.
  • Audit trail: Past assignments remain in the database for compliance.
// Role assigned from today until end of March
var userRole = new UserRole<Guid>
{
UserId = userId,
RoleName = "contractor-admin",
ValidFrom = DateTimeOffset.UtcNow,
ValidTo = new DateTimeOffset(2026, 3, 31, 23, 59, 59, TimeSpan.Zero)
};

The EfRolePermissionStore and EfGroupRoleStore filter assignments by IClock.UtcNow, so expired grants are invisible to the authorization pipeline.


JitProvisioningService<TUser, TKey> creates or updates user entities on first external login. When a user authenticates via an external IdP (Google, Azure AD, Okta), JIT provisioning:

  1. Checks if an ExternalIdentityRecord exists for the {issuer}|{subject} pair
  2. If not found, creates a new TUser entity and links the external identity
  3. If found, updates claims and profile data from the latest token
  4. Supports SCIM-aware attribute mapping
// In Program.cs
app.UseIdentityPersistence<ApplicationUser, Guid>(builder =>
{
builder.EnableJitProvisioning();
});

The Authorization package defines the role/permission infrastructure that Identity consumes:

  • IPragmaticBuilder.UseAuthorization() — configures roles, groups, resource authorizers, policies
  • MapRole<T>() — maps an IRole to its permissions (static or composed from IRoleDefinition)
  • MapGroup<T>() — maps an IGroup to its roles
  • DefaultEndpointPolicy — default policy for all endpoints (RequireAuthenticated, AllowAnonymous)
  • ResourceAuthorizationFilter — instance-level ABAC checks via IResourceAuthorizer<T> (located in Pragmatic.Actions.Pipeline)
  • SeedFromJson() — loads additional role definitions from authorization.json
await PragmaticApp.RunAsync(args, app =>
{
app.UseAuthorization(auth =>
{
auth.MapRole<AdminRole>(r => r
.IncludeDefinition<BookingPermissions>()
.IncludeDefinition<BillingPermissions>());
auth.MapGroup<CustomerCareGroup>(g => g
.WithRole<SupportRole>()
.WithRole<ViewerRole>()
.WithDataScopes("region:eu", "region:us"));
auth.AddResourceAuthorizer<InvoiceAuthorizer>();
});
});

The HeaderUserMiddleware reads HTTP headers to construct a ClaimsPrincipal for development environments. No external IdP or JWT signing key is needed.

HeaderMaps ToSeparatorExample
X-User-IdICurrentUser.Iduser-42
X-User-NameICurrentUser.DisplayNameJane Doe
X-User-RolesIUserAuthorization.Roles,admin,manager
X-User-PermissionsIUserAuthorization.Permissions,booking.create,billing.read
X-User-TenantICurrentUser.TenantIdtenant-1
X-User-GroupsIUserAuthorization.Groups,customer-care

All headers are optional. If X-User-Id is absent, the request is treated as anonymous.


[Fact]
public async Task CreateOrder_WithPermission_Succeeds()
{
var user = Substitute.For<ICurrentUser>();
user.Id.Returns("user-42");
user.Authorization.HasPermission("orders.create").Returns(true);
var service = new OrderService(user);
var result = await service.CreateOrder(new CreateOrderRequest { /* ... */ });
result.IsSuccess.Should().BeTrue();
}
// Background job context -- full access, no HTTP
services.AddScoped<ICurrentUser>(_ => SystemUser.Instance);

Integration Tests — TenantScope + Identity

Section titled “Integration Tests — TenantScope + Identity”
[Fact]
public async Task SeedData_WithSystemUser_BypassesAuthorization()
{
using var tenant = TenantScope.BeginScope("test-tenant");
// SystemUser.Instance has FullAccessUserAuthorization
// All permission checks return true
}

See samples/Pragmatic.Identity.Samples/ for 3 runnable scenarios: ICurrentUser (AnonymousUser/SystemUser singletons, PrincipalKind, property composition), integration patterns (DI injection, claim mapping, ASP.NET Core bridge), and claims/extensions (runtime user inspection, SystemUser full access).


ProblemSolution
Magic strings for claim typesIdentityOptions centralized claim mapping
No identity in background jobsSystemUser.Instance singleton with full access
Scattered permission checksIUserAuthorization.HasPermission() with wildcard matching
Testing requires HTTP plumbingAnonymousUser / SystemUser singletons, mockable ICurrentUser
Dev environment needs IdPHeaderUserMiddleware + NoOpAuthenticationHandler
Production needs JWTUseJwtAuthentication() one-liner
Role-to-permission mappingProvider chain: claims, roles, groups, custom
Temporal role assignmentsValidFrom/ValidTo on all grant entities
External IdP user provisioningJitProvisioningService — auto-create on first login
Password managementIdentity.Local with BCrypt, lockout, reset tokens
Claim type varies by IdPIdentityOptions — configure once, override per provider
MFA detectionIAuthenticationContext.IsMfaAuthenticated
Impersonation trackingICurrentUser.ImpersonatedBy

Detailed documentation is available in the docs/ directory:

With ModuleIntegration
Pragmatic.AuthorizationPermission resolution, caching, policies, resource authorizers
Pragmatic.Actions[RequirePermission], [RequirePolicy<T>] on actions and mutations
Pragmatic.MultiTenancyICurrentUser.TenantId feeds tenant context
Pragmatic.Persistence.EFCoreIAuditable.CreatedBy/UpdatedBy populated from ICurrentUser.Id
Pragmatic.FeatureFlagsICurrentUser.Id feeds FeatureFlagContext.UserId
Pragmatic.CompositionIPragmaticBuilder.UseAuthentication(), UseJwtAuthentication()
  • .NET 10.0+
  • Pragmatic.Abstractions (interfaces)
  • Pragmatic.Authorization (permission infrastructure)

Part of the Pragmatic.Design ecosystem.