Skip to content

Development Authentication

Guide to using HeaderUserMiddleware and NoOpAuthenticationHandler for development and integration testing.

During development you rarely want to set up a full identity provider. Pragmatic.Identity provides two components that work together for header-based authentication:

  1. HeaderUserMiddleware — reads HTTP headers and creates a ClaimsPrincipal
  2. NoOpAuthenticationHandler — 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();
}

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.

HeaderRequiredDescriptionExample
X-User-IdYesUser’s unique identifier. If absent, request is anonymous.user-42
X-User-NameNoDisplay name. Defaults to "Unknown".Jane Doe
X-User-RolesNoComma-separated role names.admin,booking-manager
X-User-PermissionsNoComma-separated permission names.reservation.create,reservation.read
X-User-TenantNoTenant identifier for multi-tenancy.acme-corp
X-User-GroupsNoComma-separated group names.customer-care,vip-support

The middleware creates a ClaimsIdentity with authentication type "HeaderAuth" and maps headers to claims using IdentityOptions:

HeaderClaim TypeConfigurable Via
X-User-IdIdentityOptions.UserIdClaimType (default: http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier)Yes
X-User-NameIdentityOptions.DisplayNameClaimType (default: http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name)Yes
X-User-RolesIdentityOptions.RoleClaimType (default: http://schemas.microsoft.com/ws/2008/06/identity/claims/role)Yes
X-User-PermissionsIdentityOptions.PermissionClaimType (default: permission)Yes
X-User-TenantIdentityOptions.TenantClaimType (default: tenant_id)Yes
X-User-Groupsgroup (hardcoded)No

Multi-valued headers (roles, permissions, groups) are split by comma. Each value becomes a separate claim.

ClaimsPrincipalUserAccessor normalizes these claim types to short keys when building the Claims dictionary:

Long Claim TypeNormalized Key
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifiersub
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/namename
http://schemas.microsoft.com/ws/2008/06/identity/claims/rolerole
permissionpermission
tenant_idtenant_id

This means ICurrentUser.Claims["role"] works regardless of whether the claim came from a JWT, OIDC provider, or header middleware.

GET /api/reservations HTTP/1.1
X-User-Id: user-42
X-User-Name: Jane Doe
X-User-Roles: booking-manager,catalog-viewer
X-User-Tenant: acme-corp

This creates an ICurrentUser with:

  • Id = "user-42"
  • DisplayName = "Jane Doe"
  • IsAuthenticated = true
  • Kind = PrincipalKind.User
  • TenantId = "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.1
X-User-Id: user-42
X-User-Name: Jane Doe
X-User-Permissions: reservation.create,reservation.read,guest.read
GET /api/admin/users HTTP/1.1
X-User-Id: admin-1
X-User-Name: Admin
X-User-Roles: admin
X-User-Permissions: *
GET /api/bookings HTTP/1.1
X-User-Id: user-100
X-User-Name: Bob Smith
X-User-Roles: receptionist
X-User-Tenant: hotel-alpha
X-User-Groups: front-desk,morning-shift
GET /api/public/status HTTP/1.1

Without X-User-Id, the middleware does nothing. ICurrentUser resolves to the default (anonymous or whatever is registered).

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

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

Create .http files for manual testing:

### Login as admin
GET {{baseUrl}}/api/reservations
X-User-Id: admin-1
X-User-Name: Admin
X-User-Roles: admin
X-User-Permissions: *
### Login as booking manager
GET {{baseUrl}}/api/reservations
X-User-Id: manager-1
X-User-Name: Manager
X-User-Roles: booking-manager
X-User-Tenant: hotel-alpha
### Login as read-only user
GET {{baseUrl}}/api/reservations
X-User-Id: reader-1
X-User-Name: Reader
X-User-Permissions: reservation.read,guest.read
### Anonymous request
GET {{baseUrl}}/api/public/health
HTTP Request with X-User-Id header
|
v
HeaderUserMiddleware.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)
|
v
UseAuthentication() middleware
|
v
NoOpAuthenticationHandler.HandleAuthenticateAsync()
| Checks if Context.User.Identity.IsAuthenticated
| If yes: returns AuthenticateResult.Success (trusts the identity)
| If no: returns AuthenticateResult.NoResult (unauthenticated)
|
v
UseAuthorization() middleware
| Evaluates policies (RequireAuthenticated, RequirePermission, etc.)
|
v
Endpoint handler
| ICurrentUser resolved by ClaimsPrincipalUserAccessor
| Reads claims from HttpContext.User

HeaderUserMiddleware must run before UseAuthentication(). You register it manually in your IStartupStep.ConfigurePipeline. Ensure correct order:

app.UseMiddleware<HeaderUserMiddleware>(); // First: set identity from headers
app.UseAuthentication(); // Second: authenticate
app.UseAuthorization(); // Third: authorize

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/reservations

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