DEV Community

Horatiu
Horatiu

Posted on

Asp.Net Core and Keycloak testcontainer. Testing a secure Asp.Net Core Api using Keycloak Testcontainer

Asp.Net Core and Keycloak testcontainer

Testing a secure Asp.Net Core Api using Keycloak Testcontainer

logo

Solution and Projects setup

Create a new solution.

dotnet new sln -n KeycloakTestcontainer
Enter fullscreen mode Exit fullscreen mode

Create and add a MinimalApi project to the solution.

dotnet new webapi -n KeycloakTestcontainer.Api
dotnet sln add ./KeycloakTestcontainer.Api
Enter fullscreen mode Exit fullscreen mode

Add package Microsoft.AspNetCore.Authentication.JwtBearer for token validation. Change the version as required.

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version x.x.x
Enter fullscreen mode Exit fullscreen mode

Create and add a xUnit test project to the solution.

dotnet new xunit -n KeycloakTestcontainer.Test
dotnet sln add ./KeycloakTestcontainer.Test
Enter fullscreen mode Exit fullscreen mode

Add reference to KeycloakTestcontainer.Api project.

dotnet add reference ../KeycloakTestcontainer.Api
Enter fullscreen mode Exit fullscreen mode

Add package Testcontainers.Keycloak. Change the version as required.

dotnet add package Testcontainers.Keycloak --version x.x.x
Enter fullscreen mode Exit fullscreen mode

Add package Microsoft.AspNetCore.Mvc.Testing. It will spin up an in memory web api for testing. Change the version as required.

dotnet add package Microsoft.AspNetCore.Mvc.Testing --version x.x.x
Enter fullscreen mode Exit fullscreen mode

Add package dotnet add package FluentAssertions. Change the version as required.

dotnet add package FluentAssertions --version x.x.x
Enter fullscreen mode Exit fullscreen mode

API project setup

Add Authentication and Authorization to program.cs

var builder = WebApplication.CreateBuilder(args);
πŸ‘‡
// the realm and the client configured in the Keycloak server
var realm = "myrealm";
var client = "myclient";

builder.Services.AddAuthentication()
    .AddJwtBearer(options =>
    {
        options.Authority = $"https://localhost:8443/realms/{realm}";
        options.Audience = $"{client}";
    });
builder.Services.AddAuthorization();
πŸ‘†
var app = builder.Build();

if (app.Environment.IsDevelopment())
{
}

app.UseHttpsRedirection();
πŸ‘‡
app.UseAuthentication();
app.UseAuthorization();
πŸ‘†
app.Run();
Enter fullscreen mode Exit fullscreen mode

Add the secure endpoint.

var builder = WebApplication.CreateBuilder(args);

// the realm and the client configured in the Keycloak server
var realm = "myrealm";
var client = "myclient";

builder.Services.AddAuthentication()
    .AddJwtBearer(options =>
    {
        options.Authority = $"https://localhost:8443/realms/{realm}";
        options.Audience = $"{client}";
    });
builder.Services.AddAuthorization();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
}

app.UseHttpsRedirection();

app.UseAuthentication();
app.UseAuthorization();
πŸ‘‡
app.MapGet("api/authenticate", () =>
    Results.Ok($"{System.Net.HttpStatusCode.OK} authenticated"))
    .RequireAuthorization();
πŸ‘†
app.Run();
Enter fullscreen mode Exit fullscreen mode

Add IApiMarker.cs interface to the root of KeycloakTestcontainer.Api project.

It will be used as entry point of the WebApplicationFactory<IApiMarker>

iapimarker

Test project setup

Add ApiFactoryFixture.cs class to the KeycloakTestcontainer.Test project.

image

Add the following code to ApiFactoryFixture

using DotNet.Testcontainers.Builders;
using KeycloakTestcontainer.Api;
using Microsoft.AspNetCore.Mvc.Testing;
using Testcontainers.Keycloak;

namespace KeycloakTestcontainer.Test;

public class ApiFactoryFixture : WebApplicationFactory<IApiMarker>, IAsyncLifetime
{
    public string? BaseAddress { get; set; } = "https://localhost:8443";

    private readonly KeycloakContainer _container = new KeycloakBuilder()
        .WithImage("keycloak/keycloak:26.0")
        .WithPortBinding(8443, 8443)
        //map the realm configuration file import.json.
        .WithResourceMapping("./Import/import.json", "/opt/keycloak/data/import")
        //map the certificates
        .WithResourceMapping("./Certs", "/opt/keycloak/certs")
        .WithCommand("--import-realm")
        .WithEnvironment("KC_HTTPS_CERTIFICATE_FILE", "/opt/keycloak/certs/certificate.pem")
        .WithEnvironment("KC_HTTPS_CERTIFICATE_KEY_FILE", "/opt/keycloak/certs/certificate.key")
        .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(8443))
        .WithClean(true)
        .Build();

    public async Task InitializeAsync()
    {
        await _container.StartAsync();
    }

    async Task IAsyncLifetime.DisposeAsync()
    {
        await _container.StopAsync();
    }
}
Enter fullscreen mode Exit fullscreen mode

Add ApiFactoryFixtureCollection.cs class. Using xUnit fixture collection, only a single Keycloak container will be created for all the tests.

image

Add the following code to it.

namespace KeycloakTestcontainer.Test;

[CollectionDefinition(nameof(ApiFactoryFixtureCollection))]
public class ApiFactoryFixtureCollection : ICollectionFixture<ApiFactoryFixture>
{
}
Enter fullscreen mode Exit fullscreen mode

Now let's create the AuthenticateEndpointTests.cs test class.

image

Add the following code to it.

using FluentAssertions;
using System.Net.Http.Json;
using System.Text.Json.Nodes;

namespace KeycloakTestcontainer.Test;

[Collection(nameof(ApiFactoryFixtureCollection))]
public class AuthenticateEndpointTests(ApiFactoryFixture apiFactory)
{
    private readonly HttpClient _httpClient = apiFactory.CreateClient();
    private readonly HttpClient _client = new();
    private readonly string _baseAddress = apiFactory.BaseAddress ?? string.Empty;

    [Fact]
    public async Task AuthenticateEndpoint_WhenUserIsAuthenticated_ShouldReturnOk()
    {
        //Arrange

        //The realm and the client configured in the Keycloak server
        var realm = "myrealm";
        var client = "myclient";

        //Keycloak server token endpoint
        var url = $"{_baseAddress}/realms/{realm}/protocol/openid-connect/token";
        //Api secure endpoint 
        var apiUrl = "api/authenticate";

        //Create the url encoded body
        var data = new Dictionary<string, string>
        {
            { "grant_type", "password" },
            { "client_id", $"{client}" },
            { "username", "myuser" },
            { "password", "mypassword" }
        };

        //Get the access token from the Keycloak server
        var response = await _client.PostAsync(url, new FormUrlEncodedContent(data));
        var content = await response.Content.ReadFromJsonAsync<JsonObject>();
        var token = content?["access_token"]?.ToString();

        //Act

        //Add the access token to request header
        _httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
        //Call the Api secure endpoint
        var result = await _httpClient.GetAsync(apiUrl);

        //Assert
        result.IsSuccessStatusCode.Should().BeTrue();
        result.StatusCode.Should().Be(System.Net.HttpStatusCode.OK);
    }
    [Fact]
    public async Task AuthenticateEndpoint_WhenUserIsNotAuthenticated_ShouldReturnUnauthorized()
    {
        //Arrange

        //The realm and the client configured in the Keycloak server
        var realm = "myrealm";
        var client = "myclient";

        //Keycloak server token endpoint
        var url = $"{_baseAddress}/realms/{realm}/protocol/openid-connect/token";
        //Api secure endpoint 
        var apiUrl = "api/authenticate";

        //Create the url encoded body
        var data = new Dictionary<string, string>
        {
            { "grant_type", "password" },
            { "client_id", $"{client}" },
            { "username", "myuser" },
            { "password", "badpassword" }
        };

        //Get the access token from the Keycloak server
        var response = await _client.PostAsync(url, new FormUrlEncodedContent(data));
        var content = await response.Content.ReadFromJsonAsync<JsonObject>();
        var token = content?["access_token"]?.ToString();

        //Act

        //Add the access token to request header
        _httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
        //Call the Api secure endpoint
        var result = await _httpClient.GetAsync(apiUrl);

        //Assert
        result.IsSuccessStatusCode.Should().BeFalse();
        result.StatusCode.Should().Be(System.Net.HttpStatusCode.Unauthorized);
    }
}
Enter fullscreen mode Exit fullscreen mode

Keycloak container setup

Requirements: docker installed.

Pull the docker image

docker pull keycloak/keycloak:26.0
Enter fullscreen mode Exit fullscreen mode

To avoid the ERR_SSL_PROTOCOL_ERROR in the browser , will use the developer certificates for https connection.

Create a Certs folder in KeycloakTestcontainer.Test. Will store the certificates here.

image

Open an terminal and navigate to the folder.
Create a certificate, trust it, and export it to a PEM file including the private key:

dotnet dev-certs https -ep ./certificate.crt -p $YOUR_PASSWORD$ --trust --format PEM
Enter fullscreen mode Exit fullscreen mode

Command will generate two files, certificate.pem and certificate.key. Do not forget to add .pem and .key extensions to .gitignore.

image

Let's create a docker compose file for the initial setup of the Keycloak realm, client and users.
Add the docker-compose.yml file to KeycloakTestcontainer.Test project.

image

services:
  keycloak_server:
    image:  keycloak/keycloak:26.0
    container_name: keycloak
    command:  start-dev --import-realm
    environment:
      KC_DB: postgres
      KC_DB_URL_HOST: postgres_keycloak
      KC_DB_URL_DATABASE: keycloak
      KC_DB_USERNAME: admin
      KC_DB_PASSWORD: passw0rd
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: admin
      KC_HTTPS_CERTIFICATE_FILE: /opt/keycloak/certs/certificate.pem
      KC_HTTPS_CERTIFICATE_KEY_FILE: /opt/keycloak/certs/certificate.key
    ports:
      - "8880:8080"
      - "8443:8443"
    depends_on:
      postgres_keycloak:
        condition: service_healthy
    volumes:
      - ./Certs:/opt/keycloak/certs
    networks:
      - keycloak_network

  postgres_keycloak:
    image: postgres:16.0
    container_name: postgres
    command: postgres -c 'max_connections=200'
    restart: always
    environment:
      POSTGRES_USER: "admin"
      POSTGRES_PASSWORD: "passw0rd"
      POSTGRES_DB: "keycloak"
    ports:
      - "5433:5432"
    volumes:
      - postgres-datas:/var/lib/postgresql/data
    healthcheck:
     test: "exit 0"
    networks:
      - keycloak_network

volumes:
  postgres-datas:
networks:
  keycloak_network:
    driver: bridge
Enter fullscreen mode Exit fullscreen mode

Run the command to spin up the Keycloak container

docker compose -f .\docker-compose.yml up -d
Enter fullscreen mode Exit fullscreen mode

Open browser and open the https://localhost:8443
You'll be redirected to the login page.

image

Login with username admin and password admin
Create a new realm

image

For simplicity we'll name the realm myrealm. Click Create.

image

Create a user

Initially, the realm has no users. Use these steps to create a user:

Verify that you are still in the myrealm realm, which is shown above the word Manage.

Click Users in the left-hand menu. Click Create new user.

Fill in the form with the following values:

Username: myuser

Email: myuser@email.com

First name: any first name

Last name: any last name

Click Create.

image

This user needs a password to log in. To set the initial password:

Click Credentials at the top of the page.

Fill in the Set password form with a mypassword password.

Toggle Temporary to Off so that the user does not need to update this password at the first login.

Click Save.

image

Create Client.

Verify that you are still in the myrealm realm, which is shown above the word Manage.

Click Clients.

Click Create client

Fill in the form with the following values:

1.Client type: OpenID Connect

2.Client ID: myclient

image

Click Next.

Confirm that Direct access grants is enabled. For simplicity we'll create a public cllient.

image

Click Next.

image

Click Save.

By default the Client Audience is not mapped to the token. We have to create and map it.

Click on Client Scope on the left menu.

Click Create client scope tab button.

image

Fill in the form with the following values:

1.Name: audience

2.Type: Default

3.Toggle Display on consent screen to Off

image

Click Save.

image

Click Mapper tab

Click Configure new mapper and select Audience

Fill in the form with the following values:

1.Name: any name

2.Included Client Audience: select myclient

image

Click Save

Click Clients on nav menu, select myclient.

Click Add client scope tab, select audience and click Add default.

image

Export the realm configuration

In order to have this same configuration every time when the testcontainer is started, we will export this realm configuration to a import.json file. The file will be imported by the test container during start-up.

Add a folder named Import to the test project.

image

Open a terminal and navigate to the folder.

Identify the keyclaok container

docker ps

Access the container

docker exec -it (container id) /bin/bash

Export the realm configuration

cd /opt/keycloak/bin
./kc.sh export --file /tmp/(file name).json --realm (realm name)
Enter fullscreen mode Exit fullscreen mode

image

Copy the file from container to Import folder

docker cp {container id):/tmp/{file name}.json ./{directory name}

image

Testing

Run the tests. Both tests should pass.

image

Top comments (0)