Getting Started
Step-by-step guide to setting up authentication and identity in a Pragmatic.Design application, from zero to working auth in both development and production.
Prerequisites
Section titled “Prerequisites”- .NET 10 SDK
- A Pragmatic.Design host application (created via
PragmaticApp.RunAsync) - The Pragmatic.Authorization package (auto-detected by the source generator)
Step 1: Choose Your Authentication Strategy
Section titled “Step 1: Choose Your Authentication Strategy”Pragmatic.Identity supports a config-driven approach where the same codebase can run with development headers or production JWT, controlled by configuration.
Option A: Development Only (fastest to start)
Section titled “Option A: Development Only (fastest to start)”Add the Pragmatic.Identity.AspNetCore package reference to your host project:
<ItemGroup> <ProjectReference Include="path/to/Pragmatic.Identity.AspNetCore.csproj" /></ItemGroup>Configure NoOpAuthenticationHandler in Program.cs:
using Pragmatic.Identity;using Pragmatic.Composition.Hosting;
await PragmaticApp.RunAsync(args, app =>{ app.UseAuthentication<NoOpAuthenticationHandler>("PragmaticDefault");});This sets up header-based development auth. See Dev Authentication for header details.
Option B: JWT Authentication (development + production)
Section titled “Option B: JWT Authentication (development + production)”Add the Pragmatic.Identity.Local.Jwt package (which transitively includes Identity.Local, Identity.AspNetCore, and Identity):
<ItemGroup> <ProjectReference Include="path/to/Pragmatic.Identity.Local.Jwt.csproj" /></ItemGroup>Configure JWT in Program.cs:
using Pragmatic.Identity;using Pragmatic.Identity.Local.Jwt;using Pragmatic.Composition.Hosting;
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"] ?? "https://myapp.example.com"; jwt.Audience = app.Configuration["Jwt:Audience"]; jwt.TokenExpiration = TimeSpan.FromHours(1); }); } else { // Fallback to dev auth when no JWT key is configured app.UseAuthentication<NoOpAuthenticationHandler>("PragmaticDefault"); }});Add the JWT configuration to appsettings.json (production) or appsettings.Development.json:
{ "Jwt": { "Key": "your-256-bit-secret-key-at-least-32-characters-long", "Issuer": "https://myapp.example.com", "Audience": "https://myapp.example.com", "ExpirationHours": 1 }}Option C: External Identity Provider (Keycloak, Auth0, Entra ID)
Section titled “Option C: External Identity Provider (Keycloak, Auth0, Entra ID)”Use the standard ASP.NET Core authentication builder:
using Pragmatic.Identity;using Pragmatic.Composition.Hosting;
await PragmaticApp.RunAsync(args, app =>{ app.UseAuthentication(auth => { auth.AddOpenIdConnect("oidc", options => { options.Authority = "https://your-idp.example.com"; options.ClientId = "your-client-id"; options.ClientSecret = "your-client-secret"; // ... }); });});Step 2: Configure Authorization
Section titled “Step 2: Configure Authorization”Authorization is configured alongside authentication. The UseAuthorization() extension on IPragmaticBuilder lets you define roles, groups, and policies.
using Pragmatic.Authorization;using Pragmatic.Authorization.Configuration;
await PragmaticApp.RunAsync(args, app =>{ // ... authentication setup from Step 1 ...
app.UseAuthorization(authz => { // Default policy: require authentication for all endpoints authz.DefaultPolicy = DefaultEndpointPolicy.RequireAuthenticated;
// Define roles with their permissions authz.MapRole<AdminRole>();
authz.MapRole<ManagerRole>(r => r .WithPermissions("orders.read", "orders.create", "orders.update") .IncludeDefinition<CatalogReader>());
// Define groups that aggregate roles authz.MapGroup<CustomerCareGroup>();
// Optional: cache resolved permissions across requests authz.UsePermissionCache(TimeSpan.FromMinutes(5)); });});Define Roles
Section titled “Define Roles”Roles implement IRole and optionally IRoleDefinition for reusable permission sets:
// A simple role with wildcard accesspublic sealed class AdminRole : IRole{ public static string Name => "admin"; public static string? Description => "Full access administrator"; public static IReadOnlyList<string> DefaultPermissions => ["*"];}
// A role definition for reuse across application rolespublic sealed class CatalogReader : IRoleDefinition{ public static string Name => "catalog-reader"; public static string? Description => "Read-only access to catalog"; public static IReadOnlyList<string> Permissions => [ "catalog.amenity.read", "catalog.property.read" ];}Define Groups
Section titled “Define Groups”Groups aggregate multiple roles:
public sealed class CustomerCareGroup : IGroup{ public static string Name => "customer-care"; public static string? Description => "Customer care team"; public static IReadOnlyList<string> DefaultRoles => ["booking-manager", "catalog-viewer"];}Step 3: Access the Current User
Section titled “Step 3: Access the Current User”Inject ICurrentUser anywhere in your application to access the authenticated user’s identity:
public class OrderService(ICurrentUser currentUser){ public async Task CreateOrder(CreateOrderRequest request) { // Check authentication if (!currentUser.IsAuthenticated) throw new UnauthorizedException();
// Read user identity var userId = currentUser.Id; var tenantId = currentUser.TenantId;
// Check permissions if (!currentUser.Authorization.HasPermission("orders.create")) throw new ForbiddenException();
// Check roles if (currentUser.Authorization.IsInRole("admin")) // admin-specific logic
// Access authentication metadata var issuer = currentUser.Authentication.Issuer; var isMfa = currentUser.Authentication.IsMfaAuthenticated; }}In Domain Actions
Section titled “In Domain Actions”Domain actions receive ICurrentUser as a dependency (auto-injected by the source generator):
[DomainAction][RequirePermission("orders.create")]public partial class CreateOrder : DomainAction<OrderResult>{ private ICurrentUser _currentUser = null!;
public override async Task<Result<OrderResult, IError>> Execute(CancellationToken ct = default) { // _currentUser is available here, injected by the SG var createdBy = _currentUser.Id; // ... }}Step 4: Add Local Credential Management (Optional)
Section titled “Step 4: Add Local Credential Management (Optional)”If you want self-hosted user registration, login, and password management (instead of or alongside an external IdP), add Pragmatic.Identity.Local:
// The Local package provides these domain actions:// - RegisterUser → creates a new local identity with email + hashed password// - LoginUser → authenticates with email + password, returns LoginResult// - ChangePassword → changes password for the current user (requires auth)// - RequestPasswordReset → generates a reset token (does not reveal email existence)// - ConfirmPasswordReset → validates token and sets new passwordThese actions are exposed as endpoints by the source generator when the host references the Pragmatic.Identity.Local package. The route prefix defaults to identity/local.
Implement ILocalIdentityStore
Section titled “Implement ILocalIdentityStore”The ILocalIdentityStore interface must be implemented to provide persistence for local identities. This is typically done via EF Core:
public sealed class EfLocalIdentityStore(AppDbContext db) : ILocalIdentityStore{ public async ValueTask<LocalIdentity?> FindByEmailAsync(string email, CancellationToken ct = default) => await db.LocalIdentities.FirstOrDefaultAsync(i => i.Email == email, ct);
public async ValueTask<LocalIdentity?> FindByExternalKeyAsync(string externalKey, CancellationToken ct = default) => await db.LocalIdentities.FirstOrDefaultAsync(i => i.ExternalIdentityKey == externalKey, ct);
public async ValueTask<LocalIdentity> CreateAsync(LocalIdentity identity, CancellationToken ct = default) { db.LocalIdentities.Add(identity); await db.SaveChangesAsync(ct); return identity; }
public async ValueTask UpdateAsync(LocalIdentity identity, CancellationToken ct = default) => await db.SaveChangesAsync(ct);
public async ValueTask<bool> EmailExistsAsync(string email, CancellationToken ct = default) => await db.LocalIdentities.AnyAsync(i => i.Email == email, ct);}Step 5: Configure JWT Tokens (Optional)
Section titled “Step 5: Configure JWT Tokens (Optional)”If using Pragmatic.Identity.Local.Jwt, you can generate JWT tokens after a successful login:
// In a custom endpoint or event handler after LoginUser succeeds:public class LoginEndpoint(JwtTokenGenerator jwtGenerator){ public JwtLoginResult HandleLogin(LoginResult loginResult, UserProfile profile) { return jwtGenerator.Generate( subject: loginResult.ExternalIdentityKey, displayName: profile.DisplayName, tenantId: profile.TenantId, roles: profile.Roles, permissions: profile.Permissions); }}The returned JwtLoginResult contains the Token string and ExpiresAt timestamp.
Step 6: Add Database-Backed Authorization (Optional)
Section titled “Step 6: Add Database-Backed Authorization (Optional)”For production scenarios where role-permission mappings need to be managed at runtime (not just at compile time), add Pragmatic.Identity.Persistence. See Persistence for full details.
Verification Checklist
Section titled “Verification Checklist”After completing setup, verify:
-
ICurrentUserresolves correctly in DI (inject into a controller/action and checkId,IsAuthenticated) - In development:
X-User-Idheader creates an authenticated identity - In development: requests without
X-User-Idresult inAnonymousUser(or 401 if policy requires auth) - In production: JWT tokens validate correctly and populate
ICurrentUser - Permission checks work:
[RequirePermission("...")]on actions enforces access - Role mapping works: roles assigned to users expand to correct permissions
Common Issues
Section titled “Common Issues”ICurrentUser.Id is empty
Section titled “ICurrentUser.Id is empty”The UserIdClaimType does not match the claim in your token. Check IdentityOptions:
services.AddPragmaticIdentity(opts =>{ opts.UserIdClaimType = "sub"; // Match your token's claim type});Permissions always denied
Section titled “Permissions always denied”- Verify role-to-permission mappings are registered via
MapRole<T>() - Check that the user’s token/headers include the correct role claims
- Ensure
AddPragmaticAuthorization()is called (happens automatically withUseAuthenticationorUseJwtAuthentication)
HeaderUserMiddleware not working
Section titled “HeaderUserMiddleware not working”The middleware must run before UseAuthentication(). You must register it manually in your IStartupStep.ConfigurePipeline (e.g., app.UseMiddleware<Pragmatic.Identity.HeaderUserMiddleware>();).
Circular dependency with IUserAuthorization
Section titled “Circular dependency with IUserAuthorization”ClaimsPrincipalUserAccessor resolves IUserAuthorization lazily via IServiceProvider to break the circular dependency: ICurrentUser -> CachedPermissionResolver -> ICurrentUser. This is handled internally; no user action is needed.