--- name: identity-testing-patterns description: 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. invocable: false --- # 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` 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` 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 | Document | Description | When to Load | |----------|-------------|--------------| | [docs/bff-testing.md](docs/bff-testing.md) | BFF endpoint testing with cookie simulation, antiforgery headers, and OIDC redirect bypass | BFF testing, CookieContainer, x-csrf header, BffFactory, session simulation | | [docs/aspire-testing.md](docs/aspire-testing.md) | Full-stack Aspire testing with identity server health checks and token endpoint wiring | Aspire testing, DistributedApplicationTestingBuilder, WaitForResourceHealthyAsync, end-to-end | --- ## Testing Strategy Overview | What to test | Recommended approach | |---|---| | Token issuance (client credentials, code flow) | In-process `WebApplicationFactory` hitting `/connect/token` | | Claim mapping / `IProfileService` | Unit test with `DefaultProfileService` + mock context, or integration test | | Authorization policy requirements | `IAuthorizationService` + `TestAuthHandler` in integration test | | `IAuthorizationHandler` logic | Direct unit test with `AuthorizationHandlerContext` | | Protected API access control | `WebApplicationFactory` with `TestAuthHandler` and constructed `ClaimsPrincipal` | | BFF endpoints (login/logout/user) | `WebApplicationFactory` with cookie simulation | | EF Core store implementations | In-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 ``` ### IdentityServer WebApplicationFactory ```csharp // ✅ Factory that runs a real IdentityServer in-process public sealed class IdentityServerFactory : WebApplicationFactory { 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)); 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 { 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 ``` 2. Add packages to the test project: ```xml ``` 3. Reference the IdentityServer host project (``). 4. 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 mutate public static List Clients = [ /* ... */ ]; public static List ApiScopes = [ /* ... */ ]; public static List 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 factory) : IClassFixture> { // 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(); 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 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 ApiScopes => [ new ApiScope("api1", "Primary API"), new ApiScope("api2.read", "Read from API 2") ]; public static IEnumerable ApiResources => [ new ApiResource("api1-resource", "API 1 Resource") { Scopes = { "api1" } } ]; public static IEnumerable IdentityResources => [ new IdentityResources.OpenId(), new IdentityResources.Profile() ]; public static List Users => [ TestUserBuilder.Active("alice", "Password1!") .WithClaim("email", "alice@example.com") .WithClaim("role", "admin") .Build(), TestUserBuilder.Active("bob", "Password1!") .WithClaim("email", "bob@example.com") .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 claims, TimeSpan? lifetime = null) { var allClaims = new List { 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 project protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.ConfigureTestServices(services => { // Remove production JWT Bearer authentication var jwtDescriptor = services.FirstOrDefault( d => d.ServiceType == typeof(IConfigureOptions)); 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 pipeline public sealed class TestAuthHandler : AuthenticationHandler { public const string SchemeName = "Test"; private readonly ITestClaimsProvider _claimsProvider; public TestAuthHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, ITestClaimsProvider claimsProvider) : base(options, logger, encoder) { _claimsProvider = claimsProvider; } protected override Task 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 user public interface ITestClaimsProvider { IEnumerable? GetClaims(); } public sealed class TestClaimsProvider : ITestClaimsProvider { private IEnumerable? _claims; public void SetClaims(IEnumerable claims) => _claims = claims; public void ClearClaims() => _claims = null; public IEnumerable? GetClaims() => _claims; } ``` ### Factory Registration ```csharp public sealed class ApiFactory : WebApplicationFactory { // 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(ClaimsProvider); services.AddAuthentication(TestAuthHandler.SchemeName) .AddScheme( TestAuthHandler.SchemeName, _ => { }); }); } } ``` ### Test Using the Handler ```csharp public class ProductsApiTests : IClassFixture { 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 _userRepo; public CustomProfileServiceTests() { _userRepo = new Mock(); _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](docs/bff-testing.md) 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](docs/aspire-testing.md) 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 { ["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 CI services.AddIdentityServer(); // ✅ CORRECT — Use a static developer key in tests services.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 behavior services.AddSingleton(); // ✅ 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 CI new 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 server new 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 returned new Client { AllowedGrantTypes = GrantTypes.Code, AllowedScopes = { "profile", "api1" } // Missing openid! } // ✅ CORRECT new 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 next public class MyTests : IClassFixture { 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 IAsyncLifetime public 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 runs await _app.StartAsync(cts.Token); // Immediately request a token — connection refused // ✅ CORRECT — Wait for the identity service to be healthy first await _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 audience var token = TestTokenFactory.CreateAccessToken( subject: "user-1", audience: "wrong-api", // API expects "my-api" claims: []); // ✅ CORRECT — Audience must match ValidAudience in the token validation parameters var token = TestTokenFactory.CreateAccessToken( subject: "user-1", audience: "my-api", claims: []); ``` --- ## Resources - [Duende IdentityServer Quickstarts](https://docs.duendesoftware.com/identityserver/quickstarts/) - [Duende IdentityServer Samples — GitHub](https://github.com/DuendeSoftware/Samples/tree/main/IdentityServer) - [ASP.NET Core Integration Tests with WebApplicationFactory](https://learn.microsoft.com/aspnet/core/test/integration-tests) - [IProfileService Reference — Duende Docs](https://docs.duendesoftware.com/identityserver/reference/services/profile-service/) - [Protecting APIs with JWT — Duende Docs](https://docs.duendesoftware.com/identityserver/apis/aspnetcore/jwt/) - [ASP.NET Core Authorization Tests — Microsoft Docs](https://learn.microsoft.com/aspnet/core/security/authorization/policies) - [IdentityModel Client Library](https://docs.duendesoftware.com/identitymodel/) - [Duende BFF Samples](https://docs.duendesoftware.com/bff/samples/)