Identity Testing Patterns

by DuendeSoftwarefb32edc51982No license9 starsListed Oct 8, 2026Updated Oct 8, 2026Repository updated 4 weeks ago

Testing patterns for IdentityServer-based systems including integration testing with WebApplicationFactory, mock token issuance, test authority configuration, protocol response validation, and end-to-end authentication flow testing.

Instructions onlySoftware Development
AI-generated overview

Testing patterns for IdentityServer-based systems: in-process integration tests, mock token issuance, and authentication flow verification.

What it does
This skill provides reference patterns and code examples for testing Duende IdentityServer-based authentication systems. It covers hosting IdentityServer in-process with WebApplicationFactory, building test clients and users, issuing self-signed mock JWTs, and replacing authentication with a TestAuthHandler. It also documents BFF endpoint testing and full-stack Aspire testing in linked sub-documents.
When to use it
Use it when writing integration tests for applications that issue or validate tokens with Duende IdentityServer, when creating mock JWTs for protected APIs without a live authority, or when verifying authorization policies, claim transformation, and BFF session flows.
Requirements
Instructions only; no scripts are shipped. Examples assume .NET with xUnit, Microsoft.AspNetCore.Mvc.Testing, Duende.IdentityModel, and IdentityModel packages, plus the Duende.Templates in-memory template for one pattern. No credentials or network access are required.

Identity Testing Patterns

When to Use This Skill

Use this skill when:

  • Writing integration tests for applications that issue or validate tokens using Duende IdentityServer
  • Hosting IdentityServer in-memory with WebApplicationFactory<T> to test grant flows end-to-end
  • Creating mock JWT tokens for testing protected APIs without a live authority
  • Testing custom IProfileService implementations or claim transformation logic
  • Verifying IAuthorizationHandler and policy-based authorization against specific claim sets
  • Testing BFF endpoints that rely on cookie-based sessions and proxied API calls
  • Scaffolding an integration-test project from the duende-is-inmem template to test IdentityServer itself
  • Running a post-deployment login-flow smoke test without a headless browser

Core Principles

  1. Integration over unit — Test token issuance, claim mapping, and policy enforcement against a real (in-process) IdentityServer instance. Avoid mocking the token pipeline itself; mock only external I/O (databases, downstream services).
  2. In-process authority — Use WebApplicationFactory<T> to host IdentityServer inside the test process. This avoids network round-trips, eliminates certificate trust issues, and makes tests deterministic.
  3. Predictable signing keys — Override key management in tests with a static development signing key so token signatures are verifiable without key rotation logic.
  4. Minimal test clients — Register only the clients, scopes, and resources each test needs. Over-broad test configurations mask permission bugs.
  5. Test auth handler for API tests — When testing protected APIs in isolation (without a live token endpoint), replace JWT Bearer authentication with a TestAuthHandler that accepts a fake scheme. Never disable authorization wholesale.
  6. Builder pattern for test data — Use fluent builders for Client, ApiScope, ApiResource, and test users to keep test setup readable and reduce duplication.

Related Skills

  • identityserver-configuration — Production client and resource registration patterns
  • aspnetcore-authentication — OIDC and JWT Bearer handler configuration
  • aspnetcore-authorization — Policy definitions and requirement handlers
  • claims-authorization — IProfileService and claim pipeline internals
  • duende-bff — BFF session and proxy architecture being tested

Docs: https://docs.duendesoftware.com/identityserver/fundamentals


Sub-Documents

DocumentDescriptionWhen to Load
docs/bff-testing.md [blocked]BFF endpoint testing with cookie simulation, antiforgery headers, and OIDC redirect bypassBFF testing, CookieContainer, x-csrf header, BffFactory, session simulation
docs/aspire-testing.md [blocked]Full-stack Aspire testing with identity server health checks and token endpoint wiringAspire testing, DistributedApplicationTestingBuilder, WaitForResourceHealthyAsync, end-to-end

Testing Strategy Overview

What to testRecommended approach
Token issuance (client credentials, code flow)In-process WebApplicationFactory hitting /connect/token
Claim mapping / IProfileServiceUnit test with DefaultProfileService + mock context, or integration test
Authorization policy requirementsIAuthorizationService + TestAuthHandler in integration test
IAuthorizationHandler logicDirect unit test with AuthorizationHandlerContext
Protected API access controlWebApplicationFactory with TestAuthHandler and constructed ClaimsPrincipal
BFF endpoints (login/logout/user)WebApplicationFactory with cookie simulation
EF Core store implementationsIn-memory EF provider or isolated SQL container
Deployed login flow (smoke test)Cookie-aware HttpClient + AngleSharp HTML parsing (Pattern 10)

Pattern 1: WebApplicationFactory for IdentityServer

Host a complete IdentityServer in-memory. Override configuration to inject test clients, resources, and a static signing key.

Required NuGet Packages

xml
<ItemGroup>  <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="*" />  <PackageReference Include="xunit" Version="*" />  <PackageReference Include="xunit.runner.visualstudio" Version="*" />  <PackageReference Include="Microsoft.NET.Test.Sdk" Version="*" />  <PackageReference Include="IdentityModel" Version="*" /></ItemGroup>

IdentityServer WebApplicationFactory

csharp
// ✅ Factory that runs a real IdentityServer in-processpublic sealed class IdentityServerFactory : WebApplicationFactory<Program>{    protected override void ConfigureWebHost(IWebHostBuilder builder)    {        builder.UseEnvironment("Testing");
        builder.ConfigureTestServices(services =>        {            // Remove any existing IdentityServer registration to replace it cleanly            var descriptor = services.SingleOrDefault(                d => d.ServiceType == typeof(IConfigureOptions<IdentityServerOptions>));            if (descriptor is not null)                services.Remove(descriptor);
            services.AddIdentityServer(options =>            {                options.Events.RaiseErrorEvents = true;                options.Events.RaiseFailureEvents = true;
                // Disable automatic key management — use a static key for predictability                options.KeyManagement.Enabled = false;            })            .AddInMemoryClients(TestConfig.Clients)            .AddInMemoryApiScopes(TestConfig.ApiScopes)            .AddInMemoryApiResources(TestConfig.ApiResources)            .AddInMemoryIdentityResources(TestConfig.IdentityResources)            .AddTestUsers(TestConfig.Users)            // Static development signing key — never use this in production            .AddDeveloperSigningCredential(persistKey: false);        });    }}

Requesting a Token in a Test

csharp
[Collection("IdentityServer")]public class TokenEndpointTests : IClassFixture<IdentityServerFactory>{    private readonly HttpClient _client;
    public TokenEndpointTests(IdentityServerFactory factory)    {        _client = factory.CreateClient();    }
    [Fact]    public async Task ClientCredentials_ShouldReturnAccessToken()    {        var response = await _client.RequestClientCredentialsTokenAsync(            new ClientCredentialsTokenRequest            {                Address = "https://localhost/connect/token",                ClientId = "test.service",                ClientSecret = "test-secret",                Scope = "api1"            });
        Assert.False(response.IsError, response.Error);        Assert.NotEmpty(response.AccessToken);        Assert.Equal("Bearer", response.TokenType);    }
    [Fact]    public async Task ClientCredentials_InvalidScope_ShouldReturnError()    {        var response = await _client.RequestClientCredentialsTokenAsync(            new ClientCredentialsTokenRequest            {                Address = "https://localhost/connect/token",                ClientId = "test.service",                ClientSecret = "test-secret",                Scope = "not.allowed"  // ❌ scope not granted to this client            });
        Assert.True(response.IsError);        Assert.Equal("invalid_scope", response.Error);    }}

Testing IdentityServer Itself from the duende-is-inmem Template

The fastest way to get a real, in-process IdentityServer under test is to scaffold from the in-memory template duende-is-inmem (from the Duende.Templates NuGet package) and add a test project that references the host. Unlike the TestAuthHandler/TestTokenFactory patterns below — which mock auth in a downstream API — this exercises IdentityServer's real endpoints and token issuance (no auth mocking).

Test project setup:

  1. The test project must use the Web SDK so ASP.NET Core testing APIs resolve. Change the top of the .csproj:
xml
<!-- ❌ Default test project SDK --><Project Sdk="Microsoft.NET.Sdk">
<!-- ✅ Web SDK — required for WebApplicationFactory<T> against a web host --><Project Sdk="Microsoft.NET.Sdk.Web">
  1. Add packages to the test project:
xml
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="*" /> <!-- WebApplicationFactory<T> --><PackageReference Include="Duende.IdentityModel" Version="*" />            <!-- OIDC/OAuth client helpers -->
  1. Reference the IdentityServer host project (<ProjectReference Include="..\IdentityServerHost\IdentityServerHost.csproj" />).

  2. Make the template's static Config collections mutable so tests can add/clear clients and scopes:

csharp
// Config.cs — expose List<> instead of IEnumerable<> so tests can mutatepublic static List<Client> Clients = [ /* ... */ ];public static List<ApiScope> ApiScopes = [ /* ... */ ];public static List<IdentityResource> IdentityResources = [ /* ... */ ];

Caution: With xUnit's parallel test execution, mutating shared static Config collections causes cross-test interference. Prefer per-test collections (or a fresh factory per test) over mutating shared statics.

Test class using the primary-constructor IClassFixture:

csharp
public class IdentityServerTests(WebApplicationFactory<Program> factory)    : IClassFixture<WebApplicationFactory<Program>>{    // factory.CreateClient() serves the host over https on localhost    private readonly HttpClient _client = factory.CreateClient();
    [Fact]    public async Task Discovery_document_is_available()    {        var disco = await _client.GetDiscoveryDocumentAsync();        Assert.False(disco.IsError);    }
    [Fact]    public async Task Can_request_client_credentials_token()    {        var token = await _client.RequestClientCredentialsTokenAsync(new()        {            Address = "connect/token",            ClientId = "m2m.client",            ClientSecret = "secret",            Scope = "api1"        });
        Assert.False(token.IsError);        Assert.NotNull(token.AccessToken);    }
    [Fact]    public void Can_resolve_in_process_services()    {        // Reach into the running host's DI container        using var scope = factory.Services.CreateScope();        var profileService = scope.ServiceProvider.GetRequiredService<IProfileService>();        Assert.NotNull(profileService);    }}

The discovery/token helpers (GetDiscoveryDocumentAsync, RequestClientCredentialsTokenAsync) come from Duende.IdentityModel. Accessing factory.Services lets you assert on real in-process services such as IProfileService, stores, or options.


Pattern 2: Test Configuration Builders

Use static builders — not scattered inline literals — so every test builds from a consistent baseline.

csharp
public static class TestConfig{    public static IEnumerable<Client> Clients =>    [        ClientBuilder.ClientCredentials("test.service", "test-secret")            .WithScopes("api1", "api2.read")            .Build(),
        ClientBuilder.AuthorizationCode("test.webapp", "webapp-secret")            .WithRedirectUri("https://testapp/signin-oidc")            .WithScopes("openid", "profile", "api1")            .Build()    ];
    public static IEnumerable<ApiScope> ApiScopes =>    [        new ApiScope("api1", "Primary API"),        new ApiScope("api2.read", "Read from API 2")    ];
    public static IEnumerable<ApiResource> ApiResources =>    [        new ApiResource("api1-resource", "API 1 Resource")        {            Scopes = { "api1" }        }    ];
    public static IEnumerable<IdentityResource> IdentityResources =>    [        new IdentityResources.OpenId(),        new IdentityResources.Profile()    ];
    public static List<TestUser> Users =>    [        TestUserBuilder.Active("alice", "Password1!")            .WithClaim("email", "[email protected]")            .WithClaim("role", "admin")            .Build(),
        TestUserBuilder.Active("bob", "Password1!")            .WithClaim("email", "[email protected]")            .Build()    ];}

Client Builder

csharp
public sealed class ClientBuilder{    private readonly Client _client = new();
    public static ClientBuilder ClientCredentials(string clientId, string secret)    {        var builder = new ClientBuilder();        builder._client.ClientId = clientId;        builder._client.AllowedGrantTypes = GrantTypes.ClientCredentials;        builder._client.ClientSecrets = [new Secret(secret.Sha256())];        return builder;    }
    public static ClientBuilder AuthorizationCode(string clientId, string secret)    {        var builder = new ClientBuilder();        builder._client.ClientId = clientId;        builder._client.AllowedGrantTypes = GrantTypes.Code;        builder._client.RequirePkce = true;        builder._client.ClientSecrets = [new Secret(secret.Sha256())];        builder._client.AllowOfflineAccess = true;        return builder;    }
    public ClientBuilder WithScopes(params string[] scopes)    {        foreach (var scope in scopes)            _client.AllowedScopes.Add(scope);        return this;    }
    public ClientBuilder WithRedirectUri(string uri)    {        _client.RedirectUris.Add(uri);        return this;    }
    public Client Build() => _client;}

TestUser Builder

csharp
public sealed class TestUserBuilder{    private readonly TestUser _user = new();
    public static TestUserBuilder Active(string username, string password)    {        var builder = new TestUserBuilder();        builder._user.SubjectId = Guid.NewGuid().ToString("N");        builder._user.Username = username;        builder._user.Password = password;        builder._user.IsActive = true;        return builder;    }
    public TestUserBuilder WithSubject(string subjectId)    {        _user.SubjectId = subjectId;        return this;    }
    public TestUserBuilder WithClaim(string type, string value)    {        _user.Claims.Add(new Claim(type, value));        return this;    }
    public TestUser Build() => _user;}

Pattern 3: Mock Token Issuance

When testing a protected API in isolation (no live IdentityServer needed), issue a self-signed JWT in the test and configure the API to trust it. This avoids spinning up an IdentityServer host for every API test.

Generating a Self-Signed Test Token

csharp
public static class TestTokenFactory{    // Static key shared between the token factory and the test auth configuration    private static readonly RsaSecurityKey TestSigningKey = CreateRsaKey();
    public static SecurityKey SigningKey => TestSigningKey;
    private static RsaSecurityKey CreateRsaKey()    {        var rsa = RSA.Create(2048);        return new RsaSecurityKey(rsa) { KeyId = "test-key-1" };    }
    public static string CreateAccessToken(        string subject,        string audience,        IEnumerable<Claim> claims,        TimeSpan? lifetime = null)    {        var allClaims = new List<Claim>        {            new(JwtClaimTypes.Subject, subject),            new(JwtClaimTypes.JwtId, Guid.NewGuid().ToString())        };        allClaims.AddRange(claims);
        var tokenDescriptor = new SecurityTokenDescriptor        {            Subject = new ClaimsIdentity(allClaims),            Audience = audience,            Issuer = "https://test-authority",            Expires = DateTime.UtcNow.Add(lifetime ?? TimeSpan.FromMinutes(5)),            SigningCredentials = new SigningCredentials(                TestSigningKey,                SecurityAlgorithms.RsaSha256),            // ✅ RFC 9068: access tokens must carry typ=at+jwt            TokenType = "at+jwt"        };
        var handler = new JsonWebTokenHandler();        return handler.CreateToken(tokenDescriptor);    }}

Configuring the API to Trust the Test Token

csharp
// ✅ In WebApplicationFactory for the API projectprotected override void ConfigureWebHost(IWebHostBuilder builder){    builder.ConfigureTestServices(services =>    {        // Remove production JWT Bearer authentication        var jwtDescriptor = services.FirstOrDefault(            d => d.ServiceType == typeof(IConfigureOptions<JwtBearerOptions>));        if (jwtDescriptor is not null)            services.Remove(jwtDescriptor);
        // Replace with test-friendly JWT Bearer that trusts our static key        services.AddAuthentication("Bearer")            .AddJwtBearer("Bearer", options =>            {                options.MapInboundClaims = false;                options.TokenValidationParameters = new TokenValidationParameters                {                    ValidateIssuerSigningKey = true,                    IssuerSigningKey = TestTokenFactory.SigningKey,                    ValidateIssuer = true,                    ValidIssuer = "https://test-authority",                    ValidateAudience = true,                    ValidAudience = "my-api",                    ValidateLifetime = true,                    ClockSkew = TimeSpan.Zero                };            });    });}

Using the Test Token in a Test

csharp
[Fact]public async Task GetProducts_WithValidToken_ShouldReturn200(){    var token = TestTokenFactory.CreateAccessToken(        subject: "user-123",        audience: "my-api",        claims: [new Claim("scope", "api1"), new Claim("role", "viewer")]);
    _client.SetBearerToken(token);
    var response = await _client.GetAsync("/api/products");    Assert.Equal(HttpStatusCode.OK, response.StatusCode);}
[Fact]public async Task GetProducts_WithoutToken_ShouldReturn401(){    var response = await _client.GetAsync("/api/products");    Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);}
[Fact]public async Task DeleteProduct_WithViewerRole_ShouldReturn403(){    var token = TestTokenFactory.CreateAccessToken(        subject: "user-123",        audience: "my-api",        claims: [new Claim("scope", "api1"), new Claim("role", "viewer")]); // ❌ missing "admin"
    _client.SetBearerToken(token);
    var response = await _client.DeleteAsync("/api/products/1");    Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);}

Pattern 4: TestAuthHandler for Protected API Tests

For APIs that use [Authorize], replace the authentication handler entirely with a TestAuthHandler that accepts any pre-built ClaimsPrincipal. This gives full control over identity in each test without token serialization.

csharp
// ✅ TestAuthHandler — injects a ClaimsPrincipal directly into the pipelinepublic sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>{    public const string SchemeName = "Test";
    private readonly ITestClaimsProvider _claimsProvider;
    public TestAuthHandler(        IOptionsMonitor<AuthenticationSchemeOptions> options,        ILoggerFactory logger,        UrlEncoder encoder,        ITestClaimsProvider claimsProvider)        : base(options, logger, encoder)    {        _claimsProvider = claimsProvider;    }
    protected override Task<AuthenticateResult> HandleAuthenticateAsync()    {        var claims = _claimsProvider.GetClaims();        if (claims is null)            return Task.FromResult(AuthenticateResult.NoResult());
        var identity = new ClaimsIdentity(claims, SchemeName);        var principal = new ClaimsPrincipal(identity);        var ticket = new AuthenticationTicket(principal, SchemeName);
        return Task.FromResult(AuthenticateResult.Success(ticket));    }}
// Swap this per-test to change the authenticated userpublic interface ITestClaimsProvider{    IEnumerable<Claim>? GetClaims();}
public sealed class TestClaimsProvider : ITestClaimsProvider{    private IEnumerable<Claim>? _claims;
    public void SetClaims(IEnumerable<Claim> claims) => _claims = claims;    public void ClearClaims() => _claims = null;    public IEnumerable<Claim>? GetClaims() => _claims;}

Factory Registration

csharp
public sealed class ApiFactory : WebApplicationFactory<Program>{    // Expose so tests can configure the identity per-test    public TestClaimsProvider ClaimsProvider { get; } = new();
    protected override void ConfigureWebHost(IWebHostBuilder builder)    {        builder.ConfigureTestServices(services =>        {            services.AddSingleton<ITestClaimsProvider>(ClaimsProvider);
            services.AddAuthentication(TestAuthHandler.SchemeName)                .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(                    TestAuthHandler.SchemeName, _ => { });        });    }}

Test Using the Handler

csharp
public class ProductsApiTests : IClassFixture<ApiFactory>{    private readonly ApiFactory _factory;    private readonly HttpClient _client;
    public ProductsApiTests(ApiFactory factory)    {        _factory = factory;        _client = factory.CreateClient();    }
    [Fact]    public async Task GetProducts_AsAdmin_ShouldSucceed()    {        _factory.ClaimsProvider.SetClaims(        [            new Claim(JwtClaimTypes.Subject, "user-001"),            new Claim(JwtClaimTypes.Name, "Alice"),            new Claim("role", "admin"),            new Claim("scope", "api1")        ]);
        var response = await _client.GetAsync("/api/products");        Assert.Equal(HttpStatusCode.OK, response.StatusCode);    }
    [Fact]    public async Task GetProducts_Unauthenticated_ShouldReturn401()    {        _factory.ClaimsProvider.ClearClaims(); // No claims = not authenticated
        var response = await _client.GetAsync("/api/products");        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);    }}

Pattern 5: Testing IProfileService

Unit test IProfileService implementations directly against the ProfileDataRequestContext contract. Use real ProfileDataRequestContext instances — do not mock the context.

csharp
public class CustomProfileServiceTests{    private readonly CustomProfileService _sut;    private readonly Mock<IUserRepository> _userRepo;
    public CustomProfileServiceTests()    {        _userRepo = new Mock<IUserRepository>();        _sut = new CustomProfileService(_userRepo.Object);    }
    [Fact]    public async Task GetProfileData_ShouldIncludeRoleClaimsForAccessToken()    {        // Arrange: a subject with known sub claim        var subject = new ClaimsPrincipal(new ClaimsIdentity(        [            new Claim(JwtClaimTypes.Subject, "user-123")        ]));
        _userRepo            .Setup(r => r.GetRolesAsync("user-123", CancellationToken.None))            .ReturnsAsync(["admin", "billing"]);
        var context = new ProfileDataRequestContext(            subject: subject,            client: new Client { ClientId = "test.client" },            caller: "test",            requestedClaimTypes: [JwtClaimTypes.Role]);
        // Act        await _sut.GetProfileDataAsync(context);
        // Assert        var roles = context.IssuedClaims            .Where(c => c.Type == JwtClaimTypes.Role)            .Select(c => c.Value)            .ToList();
        Assert.Contains("admin", roles);        Assert.Contains("billing", roles);    }
    [Fact]    public async Task IsActive_WithDeactivatedUser_ShouldSetIsActiveFalse()    {        var subject = new ClaimsPrincipal(new ClaimsIdentity(        [            new Claim(JwtClaimTypes.Subject, "user-deactivated")        ]));
        _userRepo            .Setup(r => r.IsActiveAsync("user-deactivated", CancellationToken.None))            .ReturnsAsync(false);
        var context = new IsActiveContext(            subject: subject,            client: new Client { ClientId = "test.client" },            caller: "test");
        await _sut.IsActiveAsync(context);
        Assert.False(context.IsActive);    }}

Note: ProfileDataRequestContext and IsActiveContext constructors are internal to Duende IdentityServer in some versions. If the constructors are inaccessible, test through the in-process WebApplicationFactory by issuing a real token and inspecting its claims with JsonWebTokenHandler.


Pattern 6: Testing Authorization Policies

Unit Testing an IAuthorizationHandler

Test IAuthorizationHandler implementations in isolation by constructing AuthorizationHandlerContext with synthetic claims.

csharp
public class MinimumAgeHandlerTests{    private readonly MinimumAgeHandler _sut = new();
    [Fact]    public async Task HandleRequirement_WithSufficientAge_ShouldSucceed()    {        var user = new ClaimsPrincipal(new ClaimsIdentity(        [            new Claim(JwtClaimTypes.BirthDate, "1990-01-01")        ], "Bearer"));
        var requirement = new MinimumAgeRequirement(18);        var context = new AuthorizationHandlerContext(            [requirement], user, resource: null);
        await _sut.HandleAsync(context);
        Assert.True(context.HasSucceeded);    }
    [Fact]    public async Task HandleRequirement_WithInsufficientAge_ShouldNotSucceed()    {        var user = new ClaimsPrincipal(new ClaimsIdentity(        [            new Claim(JwtClaimTypes.BirthDate,                DateTime.UtcNow.AddYears(-10).ToString("yyyy-MM-dd"))        ], "Bearer"));
        var requirement = new MinimumAgeRequirement(18);        var context = new AuthorizationHandlerContext(            [requirement], user, resource: null);
        await _sut.HandleAsync(context);
        Assert.False(context.HasSucceeded);    }}

Integration Testing Policy Enforcement

Verify that policies enforce correctly against real endpoints using TestAuthHandler:

csharp
[Fact]public async Task AdminEndpoint_WithoutAdminRole_ShouldReturn403(){    _factory.ClaimsProvider.SetClaims(    [        new Claim(JwtClaimTypes.Subject, "user-002"),        new Claim("role", "viewer") // ❌ not an admin    ]);
    var response = await _client.DeleteAsync("/api/admin/users/42");
    Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);}
[Fact]public async Task AdminEndpoint_WithAdminRole_ShouldReturn204(){    _factory.ClaimsProvider.SetClaims(    [        new Claim(JwtClaimTypes.Subject, "user-001"),        new Claim("role", "admin")    ]);
    var response = await _client.DeleteAsync("/api/admin/users/42");
    Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);}

Pattern 7: Testing BFF Endpoints

BFF tests require cookie-based session simulation using CookieContainer + HttpClientHandler. Set AllowAutoRedirect = false so session redirects don't swallow status codes. Include x-csrf: 1 header on all BFF local API calls — missing it returns 400. Override the OIDC OnRedirectToIdentityProvider event to bypass external redirects in tests.

See docs/bff-testing.md [blocked] for the complete BffFactory, CookieContainer setup, and antiforgery header test examples.


Pattern 8: Testing with Aspire (Full-Stack)

Wire IdentityServer as a named Aspire resource, then use WaitForResourceHealthyAsync("idp", cts.Token) before requesting tokens. Obtain idp endpoint via _app.GetEndpoint("idp", "https") and pass it to RequestClientCredentialsTokenAsync.

See docs/aspire-testing.md [blocked] for the complete AppHost wiring and test fixture setup.


Pattern 9: Validating Issued Token Claims

After issuing a token through the in-process IdentityServer, parse the JWT and assert on its claims without making a separate network call.

csharp
[Fact]public async Task IssuedToken_ShouldContainExpectedClaims(){    var tokenResponse = await _client.RequestClientCredentialsTokenAsync(        new ClientCredentialsTokenRequest        {            Address = "https://localhost/connect/token",            ClientId = "test.service",            ClientSecret = "test-secret",            Scope = "api1"        });
    Assert.False(tokenResponse.IsError);
    // ✅ Parse without validation (signature not verifiable externally)    //    or configure validation parameters matching the dev signing key    var handler = new JsonWebTokenHandler();    var jwt = handler.ReadJsonWebToken(tokenResponse.AccessToken);
    Assert.Equal("test.service", jwt.GetClaim(JwtClaimTypes.ClientId).Value);    Assert.Contains("api1", jwt.GetClaim(JwtClaimTypes.Scope).Value.Split(' '));    Assert.Equal("https://localhost", jwt.Issuer);    Assert.True(jwt.ValidTo > DateTime.UtcNow);}

Pattern 10: Post-Deployment Login Smoke Test (No Headless Browser)

Verify a real login flow against a deployed environment without Playwright/Selenium by driving a cookie-aware HttpClient and parsing HTML with AngleSharp. This exercises the interactive authorize → login-form → post-back → redirect-back chain end to end.

dotnet add package AngleSharp
csharp
using AngleSharp.Html.Parser;
[Fact]public async Task User_can_log_in_via_the_login_form(){    // ✅ Cookie-aware client so the antiforgery + auth cookies flow across requests    var cookies = new CookieContainer();    using var handler = new HttpClientHandler { CookieContainer = cookies };    using var client = new HttpClient(handler) { BaseAddress = new Uri("https://app.example.com") };
    // 1) GET the protected URL — auto-redirects to the IdentityServer login page    var loginPage = await client.GetAsync("/protected");
    // 2) Parse the login HTML and read the antiforgery token from the form    var html = await loginPage.Content.ReadAsStringAsync();    var doc = await new HtmlParser().ParseDocumentAsync(html);    var form = doc.QuerySelector("form")!;    var antiforgery = form.QuerySelector("input[name='__RequestVerificationToken']")!        .GetAttribute("value");
    // 3) POST credentials to the form's resolved action URL    var action = new Uri(loginPage.RequestMessage!.RequestUri!, form.GetAttribute("action"));    var result = await client.PostAsync(action, new FormUrlEncodedContent(new Dictionary<string, string>    {        ["Username"] = "alice",        ["Password"] = "alice",        ["__RequestVerificationToken"] = antiforgery!,        ["button"] = "login"    }));
    // 4) Success = we ended back on the original protected host (login redirected us home)    Assert.Equal(new Uri("https://app.example.com").Host,        result.RequestMessage!.RequestUri!.Host);}

Field names (Username, Password, __RequestVerificationToken, button="login") assume the default template login form. Adjust selectors if you customized the login UI. This is a smoke test — it confirms the deployed flow works, not per-claim correctness (use the in-process patterns above for that).


Common Pitfalls

1. Not Disabling Automatic Key Management in Tests

csharp
// ❌ WRONG — Automatic key management tries to write key files to disk in CIservices.AddIdentityServer();
// ✅ CORRECT — Use a static developer key in testsservices.AddIdentityServer(options =>{    options.KeyManagement.Enabled = false;}).AddDeveloperSigningCredential(persistKey: false);

2. Disabling Authorization Entirely in Tests

csharp
// ❌ WRONG — Removing authorization makes every endpoint open; you can't test 403 behaviorservices.AddSingleton<IAuthorizationHandler, AllowAllHandler>();
// ✅ CORRECT — Use TestAuthHandler to control the identity per-test// Authorization runs normally; only the authentication source changes

3. Hard-Coding Localhost Ports

csharp
// ❌ WRONG — Port conflicts in CInew ClientCredentialsTokenRequest{    Address = "http://localhost:5001/connect/token",    ...}
// ✅ CORRECT — Use the client's BaseAddress via the factory_client = factory.CreateClient(); // BaseAddress is set to the test servernew ClientCredentialsTokenRequest{    Address = new Uri(_client.BaseAddress!, "connect/token").ToString(),    ...}

4. Forgetting to Add the openid Scope for Interactive Flows

csharp
// ❌ WRONG — Without openid scope, no ID token is returnednew Client{    AllowedGrantTypes = GrantTypes.Code,    AllowedScopes = { "profile", "api1" } // Missing openid!}
// ✅ CORRECTnew Client{    AllowedGrantTypes = GrantTypes.Code,    AllowedScopes =    {        IdentityServerConstants.StandardScopes.OpenId,        IdentityServerConstants.StandardScopes.Profile,        "api1"    }}

5. Sharing a Single HttpClient Across Tests with TestAuthHandler

csharp
// ❌ WRONG — Identity set in one test bleeds into the nextpublic class MyTests : IClassFixture<ApiFactory>{    private static readonly HttpClient _sharedClient = factory.CreateClient();    // ClaimsProvider state is shared and can be set by different tests in parallel
// ✅ CORRECT — Create a fresh client per test, or reset ClaimsProvider in IAsyncLifetimepublic async Task InitializeAsync(){    _factory.ClaimsProvider.ClearClaims();    await Task.CompletedTask;}

6. Not Awaiting Token Endpoint During Aspire Startup

csharp
// ❌ WRONG — IdentityServer may not be ready when the first test runsawait _app.StartAsync(cts.Token);// Immediately request a token — connection refused
// ✅ CORRECT — Wait for the identity service to be healthy firstawait _app.ResourceNotifications.WaitForResourceHealthyAsync("idp", cts.Token);

7. Incorrect Audience in Self-Signed Test Tokens

csharp
// ❌ WRONG — Audience in token doesn't match API's expected audiencevar token = TestTokenFactory.CreateAccessToken(    subject: "user-1",    audience: "wrong-api",  // API expects "my-api"    claims: []);
// ✅ CORRECT — Audience must match ValidAudience in the token validation parametersvar token = TestTokenFactory.CreateAccessToken(    subject: "user-1",    audience: "my-api",    claims: []);

Resources

Source and attribution

Source:DuendeSoftware/duende-skillsinskills/identity-testing-patternsat commitfb32edc

License: No license

Content belongs to its original authors. SourceWeft indexes it from a public repository.

Report or request removal

Identity Testing Patterns Agent Skill | SourceWeft