diff --git a/lowkey-vault-app/README.md b/lowkey-vault-app/README.md index a0946e19..2cf98775 100644 --- a/lowkey-vault-app/README.md +++ b/lowkey-vault-app/README.md @@ -94,6 +94,35 @@ Set `--server.port=` as an argument as usual with Spring Boot apps: java -jar lowkey-vault-app-.jar --server.port=8443 ``` +### Overriding the challenge resource URI + +The official Azure Key Vault clients verify the challenge resource URL returned by the server (see +[blog](https://devblogs.microsoft.com/azure-sdk/guidance-for-applications-using-the-key-vault-libraries/)). You can either set +`DisableChallengeResourceVerification=true` in your client, or you can configure the resource URL returned by the Lowkey Vault: + +``` +java -jar lowkey-vault-app-.jar --LOWKEY_AUTH_RESOURCE="vault.azure.net" +``` + +> [!NOTE] +> You should be running Lowkey Vault with a resolvable hostname as a subdomain of `vault.azure.net` (e.g. `lowkey.vault.azure.net`) and have appropriate SSL certificates registered if you choose to configure the auth resource. + +> [!WARNING] +> This property is only intended to be used in case you absolutely cannot disable your challenge resource verification because it raises the complexity of your setup significantly and there are no guarantees that the clients will keep working with this workaround. Therefore, this is NOT recommended to be used. Please consider following [the official guidance](https://devblogs.microsoft.com/azure-sdk/guidance-for-applications-using-the-key-vault-libraries/) instead. + +### Using the Token endpoint with a custom realm + +By default, the Token endpoint includes the `WWW-Authenticate` response header with the `Basic realm=assumed-identity` value. +If you need to change the realm (for example because you are using Managed Identity authentication with the latest Python libraries) +you can use the `LOWKEY_TOKEN_REALM` configuration property to override it as seen in the example below: + +``` +java -jar lowkey-vault-app-.jar --LOWKEY_TOKEN_REALM="local" +``` + +Using the configuration above, the value of the response header would change to `Basic realm=local`. + + ### Importing vault content at startup When you need to automatically import the contents of the vaults form a previously created JSON export, you can diff --git a/lowkey-vault-app/build.gradle b/lowkey-vault-app/build.gradle index da18d537..1a31125f 100644 --- a/lowkey-vault-app/build.gradle +++ b/lowkey-vault-app/build.gradle @@ -42,7 +42,7 @@ test { useJUnitPlatform() systemProperty("junit.jupiter.extensions.autodetection.enabled", true) systemProperty("junit.jupiter.execution.parallel.enabled", true) - systemProperty("junit.jupiter.execution.parallel.mode.default", "concurrent") + systemProperty("junit.jupiter.execution.parallel.mode.default", "same_thread") systemProperty("junit.jupiter.execution.parallel.mode.classes.default", "concurrent") } 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 index 3b645f1f..1e60d417 100644 --- 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 @@ -1,7 +1,10 @@ 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; @@ -13,10 +16,19 @@ @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(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/filter/CommonAuthHeaderFilter.java b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/filter/CommonAuthHeaderFilter.java index 29d43661..6374a6d1 100644 --- a/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/filter/CommonAuthHeaderFilter.java +++ b/lowkey-vault-app/src/main/java/com/github/nagyesta/lowkeyvault/filter/CommonAuthHeaderFilter.java @@ -6,6 +6,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; @@ -15,19 +16,26 @@ import java.io.IOException; import java.net.URI; +import java.util.Optional; import java.util.Set; @Component @Slf4j public class CommonAuthHeaderFilter extends OncePerRequestFilter { + static final String OMIT_DEFAULT = ""; private static final int DEFAULT_HTTPS_PORT = 443; - private static final String OMIT_DEFAULT = ""; private static final String PORT_SEPARATOR = ":"; private static final String HTTPS = "https://"; private static final String BEARER_FAKE_TOKEN = "Bearer resource=\"%s\", authorization_uri=\"%s\""; private final AntPathMatcher antPathMatcher = new AntPathMatcher(); private final Set skipUrisIfMatch = Set.of("/ping", "/management/**", "/api/**", "/metadata/**"); + private final String authResource; + + public CommonAuthHeaderFilter( + @lombok.NonNull @Value("${LOWKEY_AUTH_RESOURCE:}") final String authResource) { + this.authResource = authResource; + } @Override protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response, @@ -37,8 +45,12 @@ protected void doFilterInternal(final HttpServletRequest request, final HttpServ final String port = resolvePort(request.getServerPort()); final URI baseUri = URI.create(HTTPS + request.getServerName() + port); request.setAttribute(ApiConstants.REQUEST_BASE_URI, baseUri); + final URI authResourceUri = Optional.of(authResource) + .filter(anObject -> !OMIT_DEFAULT.equals(anObject)) + .map(res -> URI.create(HTTPS + res)) + .orElse(baseUri); response.setHeader(HttpHeaders.WWW_AUTHENTICATE, - String.format(BEARER_FAKE_TOKEN, baseUri, baseUri + request.getRequestURI())); + String.format(BEARER_FAKE_TOKEN, authResourceUri, baseUri + request.getRequestURI())); if (!StringUtils.hasText(request.getHeader(HttpHeaders.AUTHORIZATION))) { log.info("Sending token to client without processing payload: {}", request.getRequestURI()); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); diff --git a/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/TestConstants.java b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/TestConstants.java index 08a3e0e0..2da8950e 100644 --- a/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/TestConstants.java +++ b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/TestConstants.java @@ -46,6 +46,7 @@ private TestConstants() { public static final String LOWKEY_VAULT = "lowkey-vault"; public static final String DEFAULT_SUB = "default."; public static final String DEFAULT_LOWKEY_VAULT = DEFAULT_SUB + LOWKEY_VAULT; + public static final String AZURE_CLOUD = "vault.azure.net"; // // diff --git a/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/TestConstantsUri.java b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/TestConstantsUri.java index 8b60317b..bcd24b5e 100644 --- a/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/TestConstantsUri.java +++ b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/TestConstantsUri.java @@ -25,6 +25,7 @@ private TestConstantsUri() { public static final URI HTTPS_DEFAULT_LOWKEY_VAULT = URI.create(HTTPS + DEFAULT_SUB + LOWKEY_VAULT); public static final URI HTTPS_DEFAULT_LOWKEY_VAULT_8443 = URI.create(HTTPS_DEFAULT_LOWKEY_VAULT + PORT_8443); public static final URI HTTPS_DEFAULT_LOWKEY_VAULT_80 = URI.create(HTTPS_DEFAULT_LOWKEY_VAULT + PORT_80); + public static final URI HTTPS_AZURE_CLOUD = URI.create(HTTPS + AZURE_CLOUD); // public static String getRandomVaultUriAsString() { 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 index 8b57c251..b7eb6cca 100644 --- 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 @@ -4,17 +4,20 @@ 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 ManagedIdentityTokenController underTest = new ManagedIdentityTokenController(); + final String tokenRealm = "realm-name"; + final ManagedIdentityTokenController underTest = new ManagedIdentityTokenController(tokenRealm); final URI resource = URI.create("https://localhost:8443/"); //when @@ -26,5 +29,16 @@ void testGetShouldReturnTokenWhenCalled() { 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-app/src/test/java/com/github/nagyesta/lowkeyvault/filter/CommonAuthHeaderFilterTest.java b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/filter/CommonAuthHeaderFilterTest.java index 3b791477..91dd9edb 100644 --- a/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/filter/CommonAuthHeaderFilterTest.java +++ b/lowkey-vault-app/src/test/java/com/github/nagyesta/lowkeyvault/filter/CommonAuthHeaderFilterTest.java @@ -30,7 +30,6 @@ class CommonAuthHeaderFilterTest { - private final CommonAuthHeaderFilter underTest = new CommonAuthHeaderFilter(); @Mock private HttpServletRequest request; @Mock @@ -59,6 +58,13 @@ public static Stream hostAndPortProvider() { .build(); } + public static Stream authResourceProvider() { + return Stream.builder() + .add(Arguments.of(LOCALHOST, HTTPS_LOCALHOST)) + .add(Arguments.of(AZURE_CLOUD, HTTPS_AZURE_CLOUD)) + .build(); + } + @BeforeEach void setUp() { openMocks = MockitoAnnotations.openMocks(this); @@ -75,6 +81,7 @@ void tearDown() throws Exception { void testDoFilterInternalShouldNotCallNextOnChainWhenAuthorizationHeaderMissing(final String headerValue) throws ServletException, IOException { //given + final CommonAuthHeaderFilter underTest = new CommonAuthHeaderFilter(CommonAuthHeaderFilter.OMIT_DEFAULT); when(request.getHeader(eq(HttpHeaders.AUTHORIZATION))).thenReturn(headerValue); //when @@ -95,6 +102,7 @@ void testDoFilterInternalShouldNotCallNextOnChainWhenAuthorizationHeaderMissing( void testDoFilterInternalShouldAddTokenToResponseHeaderWhenCalled(final String headerValue) throws ServletException, IOException { //given + final CommonAuthHeaderFilter underTest = new CommonAuthHeaderFilter(CommonAuthHeaderFilter.OMIT_DEFAULT); when(request.getHeader(eq(HttpHeaders.AUTHORIZATION))).thenReturn(headerValue); //when @@ -104,11 +112,27 @@ void testDoFilterInternalShouldAddTokenToResponseHeaderWhenCalled(final String h verify(response).setHeader(eq(HttpHeaders.WWW_AUTHENTICATE), anyString()); } + @ParameterizedTest + @MethodSource("authResourceProvider") + void testDoFilterInternalShouldSetResourceOnResponseHeaderWhenCalled(final String authResource, final URI expected) + throws ServletException, IOException { + //given + final CommonAuthHeaderFilter underTest = new CommonAuthHeaderFilter(authResource); + when(request.getHeader(eq(HttpHeaders.AUTHORIZATION))).thenReturn(HEADER_VALUE); + + //when + underTest.doFilterInternal(request, response, chain); + + //then + verify(response).setHeader(eq(HttpHeaders.WWW_AUTHENTICATE), contains("resource=\"" + expected + "\"")); + } + @ParameterizedTest @MethodSource("hostAndPortProvider") void testDoFilterInternalShouldSetRequestBaseUriRequestAttributeWhenCalled( final String hostName, final int port, final String path, final URI expected) throws ServletException, IOException { //given + final CommonAuthHeaderFilter underTest = new CommonAuthHeaderFilter(CommonAuthHeaderFilter.OMIT_DEFAULT); when(request.getServerName()).thenReturn(hostName); when(request.getServerPort()).thenReturn(port); when(request.getRequestURI()).thenReturn(path); @@ -126,6 +150,7 @@ void testDoFilterInternalShouldSetRequestBaseUriRequestAttributeWhenCalled( @Test void testShouldNotFilterShouldReturnTrueWhenRequestBaseUriIsPing() { //given + final CommonAuthHeaderFilter underTest = new CommonAuthHeaderFilter(CommonAuthHeaderFilter.OMIT_DEFAULT); when(request.getRequestURI()).thenReturn("/ping"); //when @@ -135,4 +160,14 @@ void testShouldNotFilterShouldReturnTrueWhenRequestBaseUriIsPing() { Assertions.assertTrue(actual); verify(request, atLeastOnce()).getRequestURI(); } + + @Test + void testConstructorShouldThrowExceptionWhenCalledWithNull() { + //given + + //when + Assertions.assertThrows(IllegalArgumentException.class, () -> new CommonAuthHeaderFilter(null)); + + //then + exception + } }