diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 78c5ce04..e414eba8 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -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 diff --git a/README.md b/README.md index 382d6362..0ec6899e 100644 --- a/README.md +++ b/README.md @@ -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=`. +Used for metadata endpoints + +- Simulating Managed Identity Token endpoint `GET /metadata/identity/oauth2/token?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` diff --git a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/controller/ManagedIdentityTokenController.java b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/controller/ManagedIdentityTokenController.java deleted file mode 100644 index 1e60d417..00000000 --- a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/controller/ManagedIdentityTokenController.java +++ /dev/null @@ -1,34 +0,0 @@ -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.http.HttpHeaders; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import java.net.URI; - -@Slf4j -@RestController -public class ManagedIdentityTokenController { - - private final String tokenRealm; - - public ManagedIdentityTokenController( - @NonNull @Value("${LOWKEY_TOKEN_REALM:assumed-identity}") final String tokenRealm) { - this.tokenRealm = tokenRealm; - } - - @GetMapping(value = {"/metadata/identity/oauth2/token", "/metadata/identity/oauth2/token/"}) - public ResponseEntity get(@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); - } -} diff --git a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/controller/MetadataController.java b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/controller/MetadataController.java new file mode 100644 index 00000000..efab6118 --- /dev/null +++ b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/controller/MetadataController.java @@ -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 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; + } +} diff --git a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/filter/PortSeparationFilter.java b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/filter/PortSeparationFilter.java index b627d678..3e912076 100644 --- a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/filter/PortSeparationFilter.java +++ b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/filter/PortSeparationFilter.java @@ -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) { diff --git a/lowkey-vault-app/src/main/resources/application.properties b/lowkey-vault-app/src/main/resources/application.properties index fd5d3da7..cd9e5923 100644 --- a/lowkey-vault-app/src/main/resources/application.properties +++ b/lowkey-vault-app/src/main/resources/application.properties @@ -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 diff --git a/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/controller/common/ControllerRequestMappingTest.java b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/controller/common/ControllerRequestMappingTest.java index b2f4baf6..11ae1266 100644 --- a/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/controller/common/ControllerRequestMappingTest.java +++ b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/controller/common/ControllerRequestMappingTest.java @@ -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; @@ -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)) diff --git a/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/controller/common/MetadataControllerTest.java b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/controller/common/MetadataControllerTest.java new file mode 100644 index 00000000..5e3f66db --- /dev/null +++ b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/controller/common/MetadataControllerTest.java @@ -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 nullProvider() { + return Stream.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 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(); + } + } +} diff --git a/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/controller/common/TokenControllerTest.java b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/controller/common/TokenControllerTest.java deleted file mode 100644 index b7eb6cca..00000000 --- a/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/controller/common/TokenControllerTest.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.github.nagyesta.lowkeyvault.controller.common; - -import com.github.nagyesta.lowkeyvault.controller.ManagedIdentityTokenController; -import com.github.nagyesta.lowkeyvault.model.TokenResponse; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; - -import java.net.URI; -import java.util.List; - -class TokenControllerTest { - - @Test - void testGetShouldReturnTokenWhenCalled() { - //given - final String tokenRealm = "realm-name"; - final ManagedIdentityTokenController underTest = new ManagedIdentityTokenController(tokenRealm); - final URI resource = URI.create("https://localhost:8443/"); - - //when - final ResponseEntity actual = underTest.get(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=" + tokenRealm), actual.getHeaders().get(HttpHeaders.WWW_AUTHENTICATE)); - } - - @Test - void testConstructorShouldThrowExceptionWhenCalledWithNull() { - //given - - //when - Assertions.assertThrows(IllegalArgumentException.class, () -> new ManagedIdentityTokenController(null)); - - //then + exception - } -} diff --git a/lowkey-vault-testcontainers/src/main/java/com/github/nagyesta/lowkeyvault/testcontainers/LowkeyVaultContainer.java b/lowkey-vault-testcontainers/src/main/java/com/github/nagyesta/lowkeyvault/testcontainers/LowkeyVaultContainer.java index 42dcf979..ebfb5b4a 100644 --- a/lowkey-vault-testcontainers/src/main/java/com/github/nagyesta/lowkeyvault/testcontainers/LowkeyVaultContainer.java +++ b/lowkey-vault-testcontainers/src/main/java/com/github/nagyesta/lowkeyvault/testcontainers/LowkeyVaultContainer.java @@ -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; @@ -26,6 +33,7 @@ public class LowkeyVaultContainer extends GenericContainer 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. @@ -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); + } + } } diff --git a/lowkey-vault-testcontainers/src/test/java/com/github/nagyesta/lowkeyvault/testcontainers/LowkeyVaultContainerJupiterTest.java b/lowkey-vault-testcontainers/src/test/java/com/github/nagyesta/lowkeyvault/testcontainers/LowkeyVaultContainerJupiterTest.java index d6e08961..a872148d 100644 --- a/lowkey-vault-testcontainers/src/test/java/com/github/nagyesta/lowkeyvault/testcontainers/LowkeyVaultContainerJupiterTest.java +++ b/lowkey-vault-testcontainers/src/test/java/com/github/nagyesta/lowkeyvault/testcontainers/LowkeyVaultContainerJupiterTest.java @@ -6,12 +6,14 @@ import com.github.nagyesta.lowkeyvault.http.AuthorityOverrideFunction; import org.apache.http.conn.ssl.DefaultHostnameVerifier; import org.apache.http.conn.ssl.TrustSelfSignedStrategy; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.testcontainers.images.PullPolicy; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.utility.DockerImageName; +import java.security.KeyStore; import java.util.Collections; import java.util.Map; import java.util.Set; @@ -65,12 +67,30 @@ void testContainerShouldStartUpWhenCalledWithValidNamesUsingAlias() { @Test void testContainerShouldProvideTokenEndpointWhenCalledWithValidParameters() { - //given + when test container is created + //given test container is created - //then + //when final String endpoint = underTest.getTokenEndpointUrl(); + + //then final ApacheHttpClient httpClient = new ApacheHttpClient(Function.identity(), new TrustSelfSignedStrategy(), new DefaultHostnameVerifier()); verifyTokenEndpointIsWorking(endpoint, httpClient); } + + @Test + void testContainerShouldProvideDefaultKeyStoreWhenRequested() throws Exception { + //given test container is created + + //when + final String password = underTest.getDefaultKeyStorePassword(); + final KeyStore keyStore = underTest.getDefaultKeyStore(); + + //then + Assertions.assertNotNull(keyStore); + Assertions.assertNotNull(password); + Assertions.assertTrue(keyStore.containsAlias(ALIAS), "Key store does not contain lowkey-vault.local"); + Assertions.assertNotNull(keyStore.getKey(ALIAS, password.toCharArray()), "Could not retrieve key from key store"); + Assertions.assertNotNull(keyStore.getCertificate(ALIAS)); + } }