Integration Testing .NET Auth with Duende IdentityServer

Khalid Abuhakmeh |

Getting all the moving parts to work together can be daunting when building and maintaining a .NET auth solution. Developers are adopting OAuth 2.0 and OpenID Connect for authorization and authentication, powered by Duende IdentityServer, to quickly provide a tested and mature security solution more easily. With the Duende-provided SDK, developers can customize their instance of IdentityServer, but still want to ensure it works as intended.

In this post, we’ll show you how to take your existing IdentityServer instance, or even an IdentityServer development instance, and use it with your favorite unit testing framework. We’ll also show you how to add a special test client for a unit test.

Setting Up The Solution

Most developers treat an Identity Provider like an appliance. Although it’s a critical component of your solution, it's typically in the background doing meaningful work. Developers who want the most accurate OAuth 2.0 or OpenID Connect experience in their test suite can run an in-process instance of IdentityServer.

We recommend starting with the isinmem template, which you can find in the Duende.Templates NuGet package.

dotnet new install Duende.Templates

The reason is that, as the name suggests, much of the data required to run IdentityServer is in memory. As a starting point, the template allows for the most straightforward and flexible implementation of an identity provider (IdP) during tests.

Next, we’ll need to add a unit testing project. You’re welcome to choose any, but we’ll select xUnit for this post.

We should now have a solution with at least two projects: IdentityServer and a Unit Test project.

Integration Testing Authentication

Let’s start by modifying the IdentityServer host project so that our unit test project can access its internal dependencies. Add a new ItemGroup section to your IdentityServer host .csproj, with the Include value being the name of the unit test project.

<ItemGroup>
    <InternalsVisibleTo Include="IdentityServer.Integration.Tests" />
</ItemGroup>

Next, we’ll need to add a new partial class to use as a marker in our unit test project. Add the following line to the end of your Program.cs file.

public partial class Program;

Note that this step is no longer required if you use .NET 10 or higher, as the Program class is public by default in newer versions of .NET.

Moving on to our unit test project, we’ll need to change the SDK value to use the web SDK provided by the current .NET installation. Change the top line of your unit test project’s .csproj to the following value.

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

The SDK value change is necessary since we’ll be using many web APIs, and they would not be available under the typical SDK value of Microsoft.Net.Sdk.

Next, let’s add the most essential dependency, Microsoft.AspNetCore.Mvc.Testing, which will allow us to run an in-memory instance of IdentityServer. In the same .csproj file, add the following PackageReference.

<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.5" />

We should be ready to start writing some tests now.

Writing Integration Tests With IdentityServer

In our IdentityServer instance, we should make a few changes to our Config class, which is a static class that holds our in-memory IdentityServer configuration. Replace the template’s default Config with the following code.

using Duende.IdentityServer.Models;

namespace IdentityServer.Integration;

public static class Config
{
    public static List<IdentityResource> IdentityResources { get; internal set; } =
    [
        new IdentityResources.OpenId(),
        new IdentityResources.Profile()
    ];

    public static IEnumerable<ApiScope> ApiScopes { get; internal set; } =
    [
        new("scope1"),
        new("scope2")
    ];

    public static List<Client> Clients { get; internal set; } =
    [
        new()
        {
            ClientId = "m2m.client",
            ClientName = "Client Credentials Client",

            AllowedGrantTypes = GrantTypes.ClientCredentials,
            ClientSecrets = { new Secret("511536EF-F270-4058-80CA-1C89C192F69A".Sha256()) },

            AllowedScopes = { "scope1" }
        },
        // interactive client using code flow + pkce
        new()
        {
            ClientId = "interactive",
            ClientSecrets = { new Secret("49C1A7E1-0C79-4A89-A3D6-A37998FB86B0".Sha256()) },

            AllowedGrantTypes = GrantTypes.Code,

            RedirectUris = { "https://localhost:44300/signin-oidc" },
            FrontChannelLogoutUri = "https://localhost:44300/signout-oidc",
            PostLogoutRedirectUris = { "https://localhost:44300/signout-callback-oidc" },

            AllowOfflineAccess = true,
            AllowedScopes = { "openid", "profile", "scope2" }
        }
    ];
}

The notable change to the version of this class that is included in the template is that all collections are now modifiable at runtime. We’ll use this in a test later, but first, let’s set up a new test class.

using System.Net;
using Duende.IdentityModel.Client;
using Duende.IdentityServer.Licensing;
using Duende.IdentityServer.Models;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Xunit.Abstractions;
using IdP = Program;

namespace IdentityServer.Integration.Tests;

public class IdentityServerTests(
    WebApplicationFactory<IdP> factory,
    ITestOutputHelper output
) : IClassFixture<WebApplicationFactory<dIP>>
{

}

The Microsoft.AspNetCore.Mvc.Testing package allows you to inject an instance of WebApplicationFactory, which uses the Program from our IdentityServer host as a generic type declaration. I chose to alias the Program class name to IdP to clarify what and where the Program symbol is coming from and its role in our solution.

Let’s write our first test, accessing the discovery document.

[Fact]
public async Task Can_get_discovery_document()
{
    var client = factory.CreateClient(new()
    {
        BaseAddress = new Uri("https://localhost/")
    });

    var response = await client.GetAsync("/.well-known/openid-configuration");

    Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    var document = await response.Content.ReadAsStringAsync();
    output.WriteLine(document);
}

Running the test, IdentityServer will return the discovery document. That was easy! The IdentityServer instance runs in memory and has a domain named localhost. It also supports the HTTPS protocol, a nice added benefit of this approach.

The call to factory.CreateClient creates an HttpClient specifically targeting our IdentityServer instance, so it’s straightforward to interact with any endpoints on the application. Let’s write a test that gets an access token using the m2m.client client found in our Config file.

[Fact]
public async Task Can_get_token()
{
    var client = factory.CreateClient(new()
    {
        BaseAddress = new Uri("https://localhost/")
    });

    var response = await client.RequestClientCredentialsTokenAsync(
        new ClientCredentialsTokenRequest
        {
            Address = "/connect/token",
            ClientId = "m2m.client",
            ClientSecret = "511536EF-F270-4058-80CA-1C89C192F69A"
        });

    Assert.Equal(HttpStatusCode.OK, response.HttpStatusCode);
    Assert.NotNull(response.AccessToken);

    output.WriteLine(response.AccessToken);
}

We should now have a valid access token issued by IdentityServer. Great! But what if we need to alter some configuration options during a test? Well, we can do that. In the next test, let’s clear our client collection and add a custom client just for our test.

[Fact]
public async Task Can_add_client_for_test()
{
    var client = factory.WithWebHostBuilder(b =>
    {
        b.ConfigureTestServices(_ =>
        {
            Config.Clients.Clear();
            Config.Clients.Add(new Client
            {
                ClientId = "m2m.client.test",
                ClientName = "Test Client",
                AllowedGrantTypes = GrantTypes.ClientCredentials,
                ClientSecrets = { new Secret("secret".Sha256()) },
                AllowedScopes = { "scope1" }
            });
        });
    }).CreateClient(new()
    {
        BaseAddress = new Uri("https://localhost/")
    });

    var response = await client.RequestClientCredentialsTokenAsync(
        new ClientCredentialsTokenRequest
        {
            Address = "/connect/token",
            ClientId = "m2m.client.test",
            ClientSecret = "secret"
        });

    Assert.Equal(HttpStatusCode.OK, response.HttpStatusCode);
    Assert.NotNull(response.AccessToken);

    output.WriteLine(response.AccessToken);
}

You can use IdentityServer in a pre-configured approach or customize it for testing use cases.

Finally, since we have access to the running instance of IdentityServer, we can also access internal dependencies using the services collection.

[Fact]
public void Can_get_license_usage_summary()
{
    var summary = factory.Services.GetRequiredService<LicenseUsageSummary>();

    Assert.NotNull(summary);
    output.WriteLine(summary.LicenseEdition);
}

Using the Services property to access dependencies is an excellent technique for understanding failing tests better when IdentityServer has customizations beyond the out-of-the-box implementation.

Conclusion

As you’ve seen, using IdentityServer in integration tests couldn’t be more straightforward. Leveraging the in-memory template is a great starting point for building your instance of IdentityServer or creating a test appliance you and your development team can use to test your business solutions that rely on OAuth and OpenID Connect.

You can access a complete sample solution at this GitHub Repository, which includes the code in this post.

To learn more about integration testing ASP.NET Core applications, please see the original documentation this post is based on at Microsoft Learn. Also, if you have any questions or ideas, please visit our Duende community discussions to learn more from our amazing Duende community.