Skip to content

Commit

Permalink
Improve Testcontainers Module (#1288)
Browse files Browse the repository at this point in the history
- Adds new endpoints serving the default keystore and keystore password
- Adds new methods to the Testcontainers module to obtain the keystore and password
- Adds new test cases to cover new functionality
- Skips PR labeling from forks

Resolves #1285
{minor}

Signed-off-by: Esta Nagy <nagyesta@gmail.com>
  • Loading branch information
nagyesta authored Dec 25, 2024
1 parent 8728523 commit d0f902e
Show file tree
Hide file tree
Showing 11 changed files with 243 additions and 83 deletions.
1 change: 1 addition & 0 deletions .github/workflows/pr-labeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Label PR
if: ${{ github.repository_owner == 'nagyesta' }}
uses: TimonVS/pr-labeler-action@f9c084306ce8b3f488a8f3ee1ccedc6da131d1af # v5.0.0
with:
configuration-path: .github/pr-labeler.yml # optional, .github/pr-labeler.yml is the default value
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,10 +198,15 @@ Lowkey Vault is far from supporting all Azure Key Vault features. The list suppo

#### HTTP `:8080`

Only used for simulating Managed Identity Token endpoint `/metadata/identity/oauth2/token?resource=<resource>`.
Used for metadata endpoints

- Simulating Managed Identity Token endpoint `GET /metadata/identity/oauth2/token?resource=<resource>`.
- Obtaining the default certificates of Lowkey Vault
- The default `PKCS12` keystore: `GET /metadata/default-cert/lowkey-vault.p12`
- The password protecting the default keystore: `GET /metadata/default-cert/password`

> [!TIP]
> This endpoint provides the same Managed Identity stub as [Assumed Identity](https://github.com/nagyesta/assumed-identity). If you want to use Lowkey Vault with Managed Identity, this functionality allows you to do so with a single container.
> Managed Identity Token endpoint provides the same Managed Identity stub as [Assumed Identity](https://github.com/nagyesta/assumed-identity). If you want to use Lowkey Vault with Managed Identity, this functionality allows you to do so with a single container.
#### HTTPS `:8443`

Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.github.nagyesta.lowkeyvault.controller;

import com.github.nagyesta.lowkeyvault.model.TokenResponse;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.util.MimeTypeUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;

import java.io.IOException;
import java.net.URI;

@Slf4j
@RestController
public class MetadataController {

private final String tokenRealm;
private final byte[] keyStoreContent;
private final String keyStorePassword;

public MetadataController(
@NonNull @Value("${LOWKEY_TOKEN_REALM:assumed-identity}") final String tokenRealm,
@NonNull @Value("${default-keystore-resource}") final String keyStoreResource,
@NonNull @Value("${default-keystore-password}") final String keyStorePassword) throws IOException {
this.tokenRealm = tokenRealm;
this.keyStoreContent = new ClassPathResource(keyStoreResource).getContentAsByteArray();
this.keyStorePassword = keyStorePassword;
}

@GetMapping(value = {"/metadata/identity/oauth2/token", "/metadata/identity/oauth2/token/"})
public ResponseEntity<TokenResponse> getManagedIdentityToken(@RequestParam("resource") final URI resource) {
final TokenResponse body = new TokenResponse(resource);
log.info("Returning token: {}", body);
return ResponseEntity.ok()
.header(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=" + tokenRealm)
.body(body);
}

@GetMapping(value = "/metadata/default-cert/lowkey-vault.p12",
produces = MimeTypeUtils.APPLICATION_OCTET_STREAM_VALUE)
public @ResponseBody byte[] getDefaultCertificateStoreContent() {
log.info("Returning default certificate store.");
return keyStoreContent;
}

@GetMapping(value = "/metadata/default-cert/password",
produces = MimeTypeUtils.TEXT_PLAIN_VALUE)
public @ResponseBody String getDefaultCertificateStorePassword() {
log.info("Returning default certificate store password.");
return keyStorePassword;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ protected void doFilterInternal(
final FilterChain filterChain)
throws ServletException, IOException {
final var secure = request.isSecure();
final boolean isTokenRequest = request.getRequestURI().startsWith("/metadata/identity/oauth2/token");
final boolean isTokenRequest = request.getRequestURI().startsWith("/metadata/");
final boolean unsecureTokenRequest = isTokenRequest && !secure;
final boolean secureVaultRequest = !isTokenRequest && secure;
if (unsecureTokenRequest || secureVaultRequest) {
Expand Down
2 changes: 2 additions & 0 deletions lowkey-vault-app/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ server.ssl.protocol=TLS
server.ssl.enabled-protocols=TLSv1.2
server.ssl.enabled=true
server.ssl.key-store-type=PKCS12
default-keystore-resource=cert/keystore.p12
server.ssl.key-store=classpath:cert/keystore.p12
default-keystore-password=changeit
server.ssl.key-store-password=changeit
server.tomcat.additional-tld-skip-patterns=*.jar
server.error.include-binding-errors=always
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.github.nagyesta.lowkeyvault.controller.common;

import com.github.nagyesta.lowkeyvault.controller.MetadataController;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
Expand All @@ -26,6 +27,7 @@ void testControllerEndpointShouldHaveBothMissingAndPresentTrailingSlashWhenAnnot

//when
streamAllControllerClasses()
.filter(c -> !c.equals(MetadataController.class))
.map(Class::getDeclaredMethods)
.flatMap(Arrays::stream)
.filter(method -> method.isAnnotationPresent(GetMapping.class))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package com.github.nagyesta.lowkeyvault.controller.common;

import com.github.nagyesta.lowkeyvault.controller.MetadataController;
import com.github.nagyesta.lowkeyvault.model.TokenResponse;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URL;
import java.util.List;
import java.util.stream.Stream;

class MetadataControllerTest {

private static final String KEY_STORE_PASSWORD = "changeit";
private static final String KEY_STORE_RESOURCE = "cert/keystore.p12";
private static final String REALM_NAME = "realm-name";

public static Stream<Arguments> nullProvider() {
return Stream.<Arguments>builder()
.add(Arguments.of(null, null, null))
.add(Arguments.of(REALM_NAME, null, null))
.add(Arguments.of(null, KEY_STORE_RESOURCE, null))
.add(Arguments.of(null, null, KEY_STORE_PASSWORD))
.add(Arguments.of(null, KEY_STORE_RESOURCE, KEY_STORE_PASSWORD))
.add(Arguments.of(REALM_NAME, null, KEY_STORE_PASSWORD))
.add(Arguments.of(REALM_NAME, KEY_STORE_RESOURCE, null))
.build();
}

@Test
void testGetManagedIdentityTokenShouldReturnTokenWhenCalled() throws IOException {
//given
final MetadataController underTest = new MetadataController(REALM_NAME, KEY_STORE_RESOURCE, KEY_STORE_PASSWORD);
final URI resource = URI.create("https://localhost:8443/");

//when
final ResponseEntity<TokenResponse> actual = underTest.getManagedIdentityToken(resource);

//then
Assertions.assertNotNull(actual);
Assertions.assertEquals(HttpStatus.OK, actual.getStatusCode());
Assertions.assertNotNull(actual.getBody());
Assertions.assertEquals(resource, actual.getBody().resource());
Assertions.assertEquals("dummy", actual.getBody().accessToken());
Assertions.assertEquals(List.of("Basic realm=" + REALM_NAME), actual.getHeaders().get(HttpHeaders.WWW_AUTHENTICATE));
}

@Test
void testGetDefaultCertificateStoreContentShouldReturnResourceContents() throws IOException {
//given
final MetadataController underTest = new MetadataController(REALM_NAME, KEY_STORE_RESOURCE, KEY_STORE_PASSWORD);
final byte[] expected = getResourceContent();

//when
final byte[] actual = underTest.getDefaultCertificateStoreContent();

//then
Assertions.assertNotNull(actual);
Assertions.assertArrayEquals(expected, actual);
}

@Test
void testGetDefaultCertificateStorePasswordShouldReturnPassword() throws IOException {
//given
final MetadataController underTest = new MetadataController(REALM_NAME, KEY_STORE_RESOURCE, KEY_STORE_PASSWORD);

//when
final String actual = underTest.getDefaultCertificateStorePassword();

//then
Assertions.assertEquals(KEY_STORE_PASSWORD, actual);
}

@ParameterizedTest
@MethodSource("nullProvider")
void testConstructorShouldThrowExceptionWhenCalledWithNull(final String realm, final String resource, final String password) {
//given

//when
Assertions.assertThrows(IllegalArgumentException.class, () -> new MetadataController(realm, resource, password));

//then + exception
}

private byte[] getResourceContent() throws IOException {
final URL url = getClass().getResource("/" + KEY_STORE_RESOURCE);
if (url == null) {
throw new IOException("Resource not found: " + KEY_STORE_RESOURCE);
}
//noinspection LocalCanBeFinal
try (InputStream inputStream = url.openStream()) {
return inputStream.readAllBytes();
}
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.utility.DockerImageName;

import java.io.ByteArrayInputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.KeyStore;
import java.util.List;
import java.util.Objects;
import java.util.Set;
Expand All @@ -26,6 +33,7 @@ public class LowkeyVaultContainer extends GenericContainer<LowkeyVaultContainer>
private static final String LOCALHOST = "localhost";
private static final String DOT = ".";
private static final String TOKEN_ENDPOINT_PATH = "/metadata/identity/oauth2/token";
private final HttpClient httpClient = HttpClient.newHttpClient();

/**
* Creates a new instance.
Expand Down Expand Up @@ -189,4 +197,42 @@ public String getPassword() {
public String getUsername() {
return DUMMY_USERNAME;
}

/**
* Returns a key store containing the default certificate shipped with Lowkey Vault.
*
* @return keyStore
*/
public KeyStore getDefaultKeyStore() {
final HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(getTokenEndpointBaseUrl() + "/metadata/default-cert/lowkey-vault.p12"))
.GET()
.build();
try {
final byte[] keyStoreBytes = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray())
.body();
final KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(new ByteArrayInputStream(keyStoreBytes), getDefaultKeyStorePassword().toCharArray());
return keyStore;
} catch (final Exception e) {
throw new IllegalStateException("Failed to get default key store", e);
}
}

/**
* Returns password protecting the default certificate shipped with Lowkey Vault.
*
* @return password
*/
public String getDefaultKeyStorePassword() {
final HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(getTokenEndpointBaseUrl() + "/metadata/default-cert/password"))
.GET()
.build();
try {
return httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)).body();
} catch (final Exception e) {
throw new IllegalStateException("Failed to get default key store password", e);
}
}
}
Loading

0 comments on commit d0f902e

Please sign in to comment.