From 02f44d3c6fb30ffec0d85ac9d9f01fbe96f58584 Mon Sep 17 00:00:00 2001 From: Esta Nagy Date: Sat, 21 Sep 2024 17:16:43 +0200 Subject: [PATCH] WWW-Authenticate resource parameter fails challenge verification even with custom hostname - Uses default value of the LOWKEY_AUTH_RESOURCE property to keep existing logic in place - Changes token endpoint to return WWW-Authenticate headers as well - Allows configuration of the realm used by the token endpoint using LOWKEY_TOKEN_REALM - Updates tests Resolves #1149 {minor} Signed-off-by: Esta Nagy --- lowkey-vault-app/README.md | 22 +++++++++++++--- lowkey-vault-app/build.gradle | 2 +- .../ManagedIdentityTokenController.java | 14 ++++++++++- .../filter/CommonAuthHeaderFilter.java | 14 ++++++++--- .../common/TokenControllerTest.java | 16 +++++++++++- .../filter/CommonAuthHeaderFilterTest.java | 25 +++++++++++-------- 6 files changed, 73 insertions(+), 20 deletions(-) diff --git a/lowkey-vault-app/README.md b/lowkey-vault-app/README.md index 6415f439..2cf98775 100644 --- a/lowkey-vault-app/README.md +++ b/lowkey-vault-app/README.md @@ -94,7 +94,7 @@ Set `--server.port=` as an argument as usual with Spring Boot apps: java -jar lowkey-vault-app-.jar --server.port=8443 ``` -## Challenge resource URI +### 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 @@ -104,8 +104,24 @@ The official Azure Key Vault clients verify the challenge resource URL returned java -jar lowkey-vault-app-.jar --LOWKEY_AUTH_RESOURCE="vault.azure.net" ``` -You should be running the 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. +> [!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 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 fe57fb65..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 @@ -16,22 +16,24 @@ 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 String authResource; + private final String authResource; - CommonAuthHeaderFilter(@Value("${LOWKEY_AUTH_RESOURCE:localhost}") final String authResource) { + public CommonAuthHeaderFilter( + @lombok.NonNull @Value("${LOWKEY_AUTH_RESOURCE:}") final String authResource) { this.authResource = authResource; } @@ -43,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, URI.create(HTTPS + authResource), 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/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 3f759f51..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 @@ -80,9 +80,8 @@ void tearDown() throws Exception { @ValueSource(strings = {EMPTY, HEADER_VALUE}) void testDoFilterInternalShouldNotCallNextOnChainWhenAuthorizationHeaderMissing(final String headerValue) throws ServletException, IOException { - final CommonAuthHeaderFilter underTest = new CommonAuthHeaderFilter(LOCALHOST); - //given + final CommonAuthHeaderFilter underTest = new CommonAuthHeaderFilter(CommonAuthHeaderFilter.OMIT_DEFAULT); when(request.getHeader(eq(HttpHeaders.AUTHORIZATION))).thenReturn(headerValue); //when @@ -102,9 +101,8 @@ void testDoFilterInternalShouldNotCallNextOnChainWhenAuthorizationHeaderMissing( @ValueSource(strings = {EMPTY, HEADER_VALUE}) void testDoFilterInternalShouldAddTokenToResponseHeaderWhenCalled(final String headerValue) throws ServletException, IOException { - final CommonAuthHeaderFilter underTest = new CommonAuthHeaderFilter(LOCALHOST); - //given + final CommonAuthHeaderFilter underTest = new CommonAuthHeaderFilter(CommonAuthHeaderFilter.OMIT_DEFAULT); when(request.getHeader(eq(HttpHeaders.AUTHORIZATION))).thenReturn(headerValue); //when @@ -118,9 +116,8 @@ void testDoFilterInternalShouldAddTokenToResponseHeaderWhenCalled(final String h @MethodSource("authResourceProvider") void testDoFilterInternalShouldSetResourceOnResponseHeaderWhenCalled(final String authResource, final URI expected) throws ServletException, IOException { - final CommonAuthHeaderFilter underTest = new CommonAuthHeaderFilter(authResource); - //given + final CommonAuthHeaderFilter underTest = new CommonAuthHeaderFilter(authResource); when(request.getHeader(eq(HttpHeaders.AUTHORIZATION))).thenReturn(HEADER_VALUE); //when @@ -134,9 +131,8 @@ void testDoFilterInternalShouldSetResourceOnResponseHeaderWhenCalled(final Strin @MethodSource("hostAndPortProvider") void testDoFilterInternalShouldSetRequestBaseUriRequestAttributeWhenCalled( final String hostName, final int port, final String path, final URI expected) throws ServletException, IOException { - final CommonAuthHeaderFilter underTest = new CommonAuthHeaderFilter(LOCALHOST); - //given + final CommonAuthHeaderFilter underTest = new CommonAuthHeaderFilter(CommonAuthHeaderFilter.OMIT_DEFAULT); when(request.getServerName()).thenReturn(hostName); when(request.getServerPort()).thenReturn(port); when(request.getRequestURI()).thenReturn(path); @@ -153,9 +149,8 @@ void testDoFilterInternalShouldSetRequestBaseUriRequestAttributeWhenCalled( @Test void testShouldNotFilterShouldReturnTrueWhenRequestBaseUriIsPing() { - final CommonAuthHeaderFilter underTest = new CommonAuthHeaderFilter(LOCALHOST); - //given + final CommonAuthHeaderFilter underTest = new CommonAuthHeaderFilter(CommonAuthHeaderFilter.OMIT_DEFAULT); when(request.getRequestURI()).thenReturn("/ping"); //when @@ -165,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 + } }