Skip to content
Introducing the next era of Duende IdentityServer. Read our CEO’s announcement

Integration Testing with Duende IdentityServer

Testing an application that relies on OAuth 2.0 and OpenID Connect can be intimidating. There are tokens to acquire, discovery documents to read, and a login flow that spans several redirects. You can avoid most of that friction by running a real, fully configured Duende IdentityServer in memory as part of your test suite and calling it like any other ASP.NET Core application.

This page shows how to stand up an in-process IdentityServer for integration testing, request tokens, and change configuration per test. For a lightweight way to smoke test the login flow of an instance you have already deployed, see Smoke Testing a Deployed Login Flow.

Most teams treat their identity provider like an appliance. It sits in the background doing critical work, and you rarely think about it until something breaks. Running an in-process instance during tests gives you accurate OAuth 2.0 and OpenID Connect behavior with real endpoints, real token issuance, and real validation. You get that without external infrastructure or a headless browser.

In-process tests are a good fit when you want to verify that:

  • Your IdentityServer configuration (clients, scopes, resources) is correct
  • Your APIs accept tokens issued by your IdentityServer
  • Any customizations, such as profile services, extension grants, or validators, behave as intended

Start from the in-memory template, duende-is-inmem, in the Duende.Templates NuGet package. As the name suggests, its configuration and users live in memory, which makes it a flexible starting point for tests.

You need at least two projects: your IdentityServer host, and a test project. This example uses xUnit, but any test framework works.

To let the test project run the host as a test server, add a reference from the test project to the host project.

In the test project, switch to the web SDK so the ASP.NET Core testing APIs are available:

.csproj
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk.Web">

Then add the Microsoft.AspNetCore.Mvc.Testing package, which provides WebApplicationFactory<T> for running the host in memory:

Terminal window
dotnet add package Microsoft.AspNetCore.Mvc.Testing

You will likely also want to use the Duende.IdentityModel package in your test project, to make consuming OpenID Connect and OAuth 2.0 endpoints more straightforward:

Terminal window
dotnet add package Duende.IdentityModel

How to Make Configuration Modifiable at Runtime

Section titled “How to Make Configuration Modifiable at Runtime”

The template ships a static Config class that holds the in-memory clients, scopes, and resources. For testing, make its collections mutable so a single test can add a purpose-built client or scope:

Config.cs
public static class Config
{
public static List<IdentityResource> IdentityResources { get; } =
[
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
];
public static List<ApiScope> ApiScopes { get; } =
[
new ApiScope("api1", "My API"),
];
public static List<Client> Clients { get; } =
[
new Client
{
ClientId = "m2m.client",
ClientSecrets = { new Secret("secret".Sha256()) },
AllowedGrantTypes = GrantTypes.ClientCredentials,
AllowedScopes = { "api1" },
},
];
}

WebApplicationFactory<Program> starts the IdentityServer host in memory and gives you an HttpClient that targets it. The instance is served from localhost over HTTPS, so you can call any endpoint directly.

Start with the discovery document. It confirms the host boots, serves the OpenID Connect metadata, and reports no configuration errors. It is the smallest useful test and a quick way to check that the fixture itself works before you write anything more involved.

IdentityServerTests.cs
public class IdentityServerTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task Discovery_document_is_available()
{
var client = factory.CreateClient();
var disco = await client.GetDiscoveryDocumentAsync();
Assert.False(disco.IsError);
}
}

Use the client from the factory to request a token with the client credentials grant, using the m2m.client from Config:

[Fact]
public async Task Can_request_client_credentials_token()
{
var client = factory.CreateClient();
var response = await client.RequestClientCredentialsTokenAsync(new()
{
Address = "connect/token",
ClientId = "m2m.client",
ClientSecret = "secret",
Scope = "api1",
});
Assert.False(response.IsError);
Assert.NotNull(response.AccessToken);
}

The RequestClientCredentialsTokenAsync and GetDiscoveryDocumentAsync helpers come from the Duende.IdentityModel package.

Because the collections in Config are mutable, a test can clear the client list and register a client tailored to the scenario under test:

[Fact]
public async Task Custom_client_can_request_token()
{
Config.Clients.Clear();
Config.Clients.Add(new Client
{
ClientId = "test.client",
ClientSecrets = { new Secret("test-secret".Sha256()) },
AllowedGrantTypes = GrantTypes.ClientCredentials,
AllowedScopes = { "api1" },
});
var client = factory.CreateClient();
var response = await client.RequestClientCredentialsTokenAsync(new()
{
Address = "connect/token",
ClientId = "test.client",
ClientSecret = "test-secret",
Scope = "api1",
});
Assert.False(response.IsError);
}

Because the host runs in process, you can reach its service provider through factory.Services. This helps when you want to assert on custom registrations or work out why a customized IdentityServer behaves in an unexpected way:

using var scope = factory.Services.CreateScope();
var profileService = scope.ServiceProvider.GetRequiredService<IProfileService>();

Integration tests validate configuration against an in-process host. To verify that a deployed instance can still complete an interactive login, see Smoke Testing a Deployed Login Flow.