Development Authentication
Guide to using HeaderUserMiddleware and NoOpAuthenticationHandler for development and integration testing.
Overview
Section titled “Overview”During development you rarely want to set up a full identity provider. Pragmatic.Identity provides two components that work together for header-based authentication:
HeaderUserMiddleware— reads HTTP headers and creates aClaimsPrincipalNoOpAuthenticationHandler— trusts the identity set by the middleware (or returns “no result” when absent)
This combination lets you test any user identity, role, and permission by simply setting HTTP headers.
using Pragmatic.Identity;using Pragmatic.Composition.Hosting;
await PragmaticApp.RunAsync(args, app =>{ app.UseAuthentication<NoOpAuthenticationHandler>("PragmaticDefault");});The HeaderUserMiddleware must be added manually to the pipeline in your IStartupStep.ConfigurePipeline. It must run before UseAuthentication(). For example, in the Showcase app:
public void ConfigurePipeline(IApplicationBuilder app){ // Identity from headers (development/test) — must run before UseAuthentication() app.UseMiddleware<Pragmatic.Identity.HeaderUserMiddleware>();
app.UseAuthentication(); app.UseAuthorization();}Config-Driven (Recommended)
Section titled “Config-Driven (Recommended)”Use JWT in production, headers in development:
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");}When no Jwt:Key is present in configuration (typical for Development environment), the app falls back to header-based auth.
HTTP Headers
Section titled “HTTP Headers”Supported Headers
Section titled “Supported Headers”| Header | Required | Description | Example |
|---|---|---|---|
X-User-Id | Yes | User’s unique identifier. If absent, request is anonymous. | user-42 |
X-User-Name | No | Display name. Defaults to "Unknown". | Jane Doe |
X-User-Roles | No | Comma-separated role names. | admin,booking-manager |
X-User-Permissions | No | Comma-separated permission names. | reservation.create,reservation.read |
X-User-Tenant | No | Tenant identifier for multi-tenancy. | acme-corp |
X-User-Groups | No | Comma-separated group names. | customer-care,vip-support |
How Headers Map to Claims
Section titled “How Headers Map to Claims”The middleware creates a ClaimsIdentity with authentication type "HeaderAuth" and maps headers to claims using IdentityOptions:
| Header | Claim Type | Configurable Via |
|---|---|---|
X-User-Id | IdentityOptions.UserIdClaimType (default: http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier) | Yes |
X-User-Name | IdentityOptions.DisplayNameClaimType (default: http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name) | Yes |
X-User-Roles | IdentityOptions.RoleClaimType (default: http://schemas.microsoft.com/ws/2008/06/identity/claims/role) | Yes |
X-User-Permissions | IdentityOptions.PermissionClaimType (default: permission) | Yes |
X-User-Tenant | IdentityOptions.TenantClaimType (default: tenant_id) | Yes |
X-User-Groups | group (hardcoded) | No |
Multi-valued headers (roles, permissions, groups) are split by comma. Each value becomes a separate claim.
Claim Normalization
Section titled “Claim Normalization”ClaimsPrincipalUserAccessor normalizes these claim types to short keys when building the Claims dictionary:
| Long Claim Type | Normalized Key |
|---|---|
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier | sub |
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name | name |
http://schemas.microsoft.com/ws/2008/06/identity/claims/role | role |
permission | permission |
tenant_id | tenant_id |
This means ICurrentUser.Claims["role"] works regardless of whether the claim came from a JWT, OIDC provider, or header middleware.
Request Examples
Section titled “Request Examples”Authenticated User with Roles
Section titled “Authenticated User with Roles”GET /api/reservations HTTP/1.1X-User-Id: user-42X-User-Name: Jane DoeX-User-Roles: booking-manager,catalog-viewerX-User-Tenant: acme-corpThis creates an ICurrentUser with:
Id="user-42"DisplayName="Jane Doe"IsAuthenticated=trueKind=PrincipalKind.UserTenantId="acme-corp"Authorization.Roles=["booking-manager", "catalog-viewer"]
Authenticated User with Direct Permissions
Section titled “Authenticated User with Direct Permissions”POST /api/reservations HTTP/1.1X-User-Id: user-42X-User-Name: Jane DoeX-User-Permissions: reservation.create,reservation.read,guest.readAdmin with Wildcard Permission
Section titled “Admin with Wildcard Permission”GET /api/admin/users HTTP/1.1X-User-Id: admin-1X-User-Name: AdminX-User-Roles: adminX-User-Permissions: *Multi-Tenant with Groups
Section titled “Multi-Tenant with Groups”GET /api/bookings HTTP/1.1X-User-Id: user-100X-User-Name: Bob SmithX-User-Roles: receptionistX-User-Tenant: hotel-alphaX-User-Groups: front-desk,morning-shiftAnonymous Request (No Authentication)
Section titled “Anonymous Request (No Authentication)”GET /api/public/status HTTP/1.1Without X-User-Id, the middleware does nothing. ICurrentUser resolves to the default (anonymous or whatever is registered).
Testing Patterns
Section titled “Testing Patterns”Integration Tests with WebApplicationFactory
Section titled “Integration Tests with WebApplicationFactory”Use the HeaderUserMiddleware to authenticate test requests:
public class ReservationTests(ShowcaseWebFactory factory) : IntegrationTestBase(factory){ [Fact] public async Task CreateReservation_WithPermission_Succeeds() { var client = Factory.CreateClient();
// Set identity headers client.DefaultRequestHeaders.Add("X-User-Id", "test-user"); client.DefaultRequestHeaders.Add("X-User-Name", "Test User"); client.DefaultRequestHeaders.Add("X-User-Roles", "booking-manager"); client.DefaultRequestHeaders.Add("X-User-Permissions", "reservation.create"); client.DefaultRequestHeaders.Add("X-User-Tenant", "test-tenant");
var response = await client.PostAsJsonAsync("/api/reservations", new { GuestId = guestId, CheckIn = "2026-04-01", CheckOut = "2026-04-05" });
response.StatusCode.Should().Be(HttpStatusCode.OK); }}Helper Extension for Test Projects
Section titled “Helper Extension for Test Projects”Create a helper to reduce boilerplate:
public static class HttpClientTestExtensions{ public static HttpClient AsUser( this HttpClient client, string userId, string? name = null, string[]? roles = null, string[]? permissions = null, string? tenantId = null, string[]? groups = null) { client.DefaultRequestHeaders.Remove("X-User-Id"); client.DefaultRequestHeaders.Remove("X-User-Name"); client.DefaultRequestHeaders.Remove("X-User-Roles"); client.DefaultRequestHeaders.Remove("X-User-Permissions"); client.DefaultRequestHeaders.Remove("X-User-Tenant"); client.DefaultRequestHeaders.Remove("X-User-Groups");
client.DefaultRequestHeaders.Add("X-User-Id", userId);
if (name is not null) client.DefaultRequestHeaders.Add("X-User-Name", name);
if (roles is { Length: > 0 }) client.DefaultRequestHeaders.Add("X-User-Roles", string.Join(",", roles));
if (permissions is { Length: > 0 }) client.DefaultRequestHeaders.Add("X-User-Permissions", string.Join(",", permissions));
if (tenantId is not null) client.DefaultRequestHeaders.Add("X-User-Tenant", tenantId);
if (groups is { Length: > 0 }) client.DefaultRequestHeaders.Add("X-User-Groups", string.Join(",", groups));
return client; }
public static HttpClient AsAdmin(this HttpClient client) => client.AsUser("admin-1", "Admin", roles: ["admin"], permissions: ["*"]);
public static HttpClient AsAnonymous(this HttpClient client) { client.DefaultRequestHeaders.Remove("X-User-Id"); client.DefaultRequestHeaders.Remove("X-User-Name"); client.DefaultRequestHeaders.Remove("X-User-Roles"); client.DefaultRequestHeaders.Remove("X-User-Permissions"); client.DefaultRequestHeaders.Remove("X-User-Tenant"); client.DefaultRequestHeaders.Remove("X-User-Groups"); return client; }}Usage:
var client = Factory.CreateClient() .AsUser("user-42", "Jane", roles: ["booking-manager"], tenantId: "acme");
var response = await client.GetAsync("/api/reservations");Testing Permission Denial
Section titled “Testing Permission Denial”[Fact]public async Task DeleteReservation_WithoutPermission_Returns403(){ var client = Factory.CreateClient() .AsUser("user-42", permissions: ["reservation.read"]); // No delete permission
var response = await client.DeleteAsync($"/api/reservations/{id}");
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);}Testing Anonymous Access
Section titled “Testing Anonymous Access”[Fact]public async Task GetReservation_AsAnonymous_Returns401(){ var client = Factory.CreateClient().AsAnonymous();
var response = await client.GetAsync($"/api/reservations/{id}");
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);}Testing with .http Files (REST Client)
Section titled “Testing with .http Files (REST Client)”Create .http files for manual testing:
### Login as adminGET {{baseUrl}}/api/reservationsX-User-Id: admin-1X-User-Name: AdminX-User-Roles: adminX-User-Permissions: *
### Login as booking managerGET {{baseUrl}}/api/reservationsX-User-Id: manager-1X-User-Name: ManagerX-User-Roles: booking-managerX-User-Tenant: hotel-alpha
### Login as read-only userGET {{baseUrl}}/api/reservationsX-User-Id: reader-1X-User-Name: ReaderX-User-Permissions: reservation.read,guest.read
### Anonymous requestGET {{baseUrl}}/api/public/healthHow It Works Internally
Section titled “How It Works Internally”Request Pipeline Flow
Section titled “Request Pipeline Flow”HTTP Request with X-User-Id header | vHeaderUserMiddleware.InvokeAsync() | Reads X-User-Id, X-User-Name, X-User-Roles, X-User-Permissions, X-User-Tenant, X-User-Groups | Creates ClaimsIdentity with auth type "HeaderAuth" | Sets HttpContext.User = new ClaimsPrincipal(identity) | vUseAuthentication() middleware | vNoOpAuthenticationHandler.HandleAuthenticateAsync() | Checks if Context.User.Identity.IsAuthenticated | If yes: returns AuthenticateResult.Success (trusts the identity) | If no: returns AuthenticateResult.NoResult (unauthenticated) | vUseAuthorization() middleware | Evaluates policies (RequireAuthenticated, RequirePermission, etc.) | vEndpoint handler | ICurrentUser resolved by ClaimsPrincipalUserAccessor | Reads claims from HttpContext.UserKey Detail: Middleware Order
Section titled “Key Detail: Middleware Order”HeaderUserMiddleware must run before UseAuthentication(). You register it manually in your IStartupStep.ConfigurePipeline. Ensure correct order:
app.UseMiddleware<HeaderUserMiddleware>(); // First: set identity from headersapp.UseAuthentication(); // Second: authenticateapp.UseAuthorization(); // Third: authorizeLogging
Section titled “Logging”HeaderUserMiddleware logs at Debug level when it resolves a user from headers:
dbug: Pragmatic.Identity.HeaderUserMiddleware[0] User 'user-42' resolved from headers for /api/reservationsSecurity Warning
Section titled “Security Warning”HeaderUserMiddleware and NoOpAuthenticationHandler are intended for development and testing only. In production:
- Use a real authentication handler (JWT, OIDC, etc.)
- Do not expose these headers to external traffic
- The config-driven pattern (JWT key present = JWT auth, absent = dev auth) is the recommended approach