Skip to content

Commit

Permalink
Allow customization of the WWW-Authenticate headers (#1151)
Browse files Browse the repository at this point in the history
* Make WWW-Authenticate header resource parameter configurable

Signed-off-by: Craig Roberts <github@craig.craig0990.co.uk>

* 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 <nagyesta@gmail.com>

---------

Signed-off-by: Craig Roberts <github@craig.craig0990.co.uk>
Signed-off-by: Esta Nagy <nagyesta@gmail.com>
Co-authored-by: Craig Roberts <git@craig.craig0990.co.uk>
  • Loading branch information
nagyesta and Craig Roberts authored Sep 21, 2024
1 parent 6c5c13e commit f38f7e0
Show file tree
Hide file tree
Showing 8 changed files with 110 additions and 6 deletions.
29 changes: 29 additions & 0 deletions lowkey-vault-app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,35 @@ Set `--server.port=<port>` as an argument as usual with Spring Boot apps:
java -jar lowkey-vault-app-<version>.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-<version>.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-<version>.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
Expand Down
2 changes: 1 addition & 1 deletion lowkey-vault-app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<TokenResponse> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<String> 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,
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
//</editor-fold>

//<editor-fold defaultstate="collapsed" desc="Port">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
//</editor-fold>

public static String getRandomVaultUriAsString() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@

class CommonAuthHeaderFilterTest {

private final CommonAuthHeaderFilter underTest = new CommonAuthHeaderFilter();
@Mock
private HttpServletRequest request;
@Mock
Expand Down Expand Up @@ -59,6 +58,13 @@ public static Stream<Arguments> hostAndPortProvider() {
.build();
}

public static Stream<Arguments> authResourceProvider() {
return Stream.<Arguments>builder()
.add(Arguments.of(LOCALHOST, HTTPS_LOCALHOST))
.add(Arguments.of(AZURE_CLOUD, HTTPS_AZURE_CLOUD))
.build();
}

@BeforeEach
void setUp() {
openMocks = MockitoAnnotations.openMocks(this);
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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);
Expand All @@ -126,6 +150,7 @@ void testDoFilterInternalShouldSetRequestBaseUriRequestAttributeWhenCalled(
@Test
void testShouldNotFilterShouldReturnTrueWhenRequestBaseUriIsPing() {
//given
final CommonAuthHeaderFilter underTest = new CommonAuthHeaderFilter(CommonAuthHeaderFilter.OMIT_DEFAULT);
when(request.getRequestURI()).thenReturn("/ping");

//when
Expand All @@ -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
}
}

0 comments on commit f38f7e0

Please sign in to comment.