Skip to content

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.

  • .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";
// ...
});
});
});

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));
});
});

Roles implement IRole and optionally IRoleDefinition for reusable permission sets:

// A simple role with wildcard access
public 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 roles
public 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"
];
}

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"];
}

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;
}
}

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 password

These 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.

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);
}

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.

After completing setup, verify:

  • ICurrentUser resolves correctly in DI (inject into a controller/action and check Id, IsAuthenticated)
  • In development: X-User-Id header creates an authenticated identity
  • In development: requests without X-User-Id result in AnonymousUser (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

The UserIdClaimType does not match the claim in your token. Check IdentityOptions:

services.AddPragmaticIdentity(opts =>
{
opts.UserIdClaimType = "sub"; // Match your token's claim type
});
  1. Verify role-to-permission mappings are registered via MapRole<T>()
  2. Check that the user’s token/headers include the correct role claims
  3. Ensure AddPragmaticAuthorization() is called (happens automatically with UseAuthentication or UseJwtAuthentication)

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.