← Files Duende SkillsARCHIVED FILE

skills/identity-testing-patterns/SKILL.md

34.6 KB · Oct 5, 2026 · 18:31 UTC

↓ Download file

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

| 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
<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-process
public 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">
```

2. 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 -->
```

3. Reference the IdentityServer host project (`<ProjectReference Include="..\IdentityServerHost\IdentityServerHost.csproj" />`).

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<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", "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<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 project
protected 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 pipeline
public 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 user
public 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](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<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 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<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 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<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 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/)

SHA-256: c81c83f87f6d515db2db9f6977a5b2a7f5d3507ee735e8c1619b7b5a2ffe02c7