Skip to content
Back to skills

Identity Testing Patterns

ASecurity

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.

  • 9 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added October 16, 2026
testingrustgosqltestinggitapidatabasesecurity

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 3 files and shows the line behind each finding

Scanned October 16, 2026

npx -y skills add DuendeSoftware/duende-skills --skill identity-testing-patterns --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Identity Testing Patterns?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Identity Testing Patterns
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/duendesoftware-identity-testing-patterns/badge)](https://www.skillsdirectory.com/skills/duendesoftware-identity-testing-patterns)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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/)

Files in this skill

  • SKILL.md34.6 KB
  • docs/aspire-testing.md1.3 KB
  • docs/bff-testing.md2.5 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.