Skip to content

Commit

Permalink
Assumed Identity endpoint in Lowkey Vault (#972)
Browse files Browse the repository at this point in the history
- Adds configuration to listen to the 8080 HTTP port for token requests
- Adds new ManagedIdentityTokenController to handle token requests
- Adds filter to enforce functional separation between the 8080 and 8443 ports
- Extends Testcontainers support with token endpoint related configuration
- Adds new tests
- Updates documentation

Updates #960
{minor}

Signed-off-by: Esta Nagy <nagyesta@gmail.com>
  • Loading branch information
nagyesta authored Apr 26, 2024
1 parent 8e1da36 commit de867f8
Show file tree
Hide file tree
Showing 19 changed files with 439 additions and 6 deletions.
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,21 @@ Lowkey Vault is far from supporting all Azure Key Vault features. The list suppo

[https://localhost:8443/api/swagger-ui/index.html](https://localhost:8443/api/swagger-ui/index.html)

### Port mappings (Default)

#### HTTP `:8080`

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

> [!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.
#### HTTPS `:8443`

- Readiness/Liveness `/ping`
- Management API
- Key Vault APIs

## Startup parameters

1. Using the `.jar`: [Lowkey Vault App](lowkey-vault-app/README.md).
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
package com.github.nagyesta.lowkeyvault;

import org.apache.catalina.connector.Connector;
import org.apache.coyote.http11.Http11NioProtocol;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.servlet.server.ServletWebServerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;

/**
Expand All @@ -12,7 +18,27 @@
@EnableWebMvc
public class LowkeyVaultApp {

@Value("${app.token.port}")
private int tokenPort;

public static void main(final String[] args) {
SpringApplication.run(LowkeyVaultApp.class, args);
}

@Bean
public ServletWebServerFactory servletContainer() {
final TomcatServletWebServerFactory tomcat = new TomcatServletWebServerFactory();
tomcat.addAdditionalTomcatConnectors(createTokenConnector());
return tomcat;
}

private Connector createTokenConnector() {
final Connector connector = new Connector("org.apache.coyote.http11.Http11NioProtocol");
final Http11NioProtocol protocol = (Http11NioProtocol) connector.getProtocolHandler();
connector.setScheme("http");
connector.setSecure(false);
connector.setPort(tokenPort);
protocol.setSSLEnabled(false);
return connector;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.github.nagyesta.lowkeyvault.controller;

import com.github.nagyesta.lowkeyvault.model.TokenResponse;
import lombok.extern.slf4j.Slf4j;
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 {

@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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public class CommonAuthHeaderFilter extends OncePerRequestFilter {
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/**");
private final Set<String> skipUrisIfMatch = Set.of("/ping", "/management/**", "/api/**", "/metadata/**");

@Override
protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.github.nagyesta.lowkeyvault.filter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
@Slf4j
public class PortSeparationFilter extends OncePerRequestFilter {

@Override
protected void doFilterInternal(
final HttpServletRequest request, final HttpServletResponse response, final FilterChain filterChain)
throws ServletException, IOException {
final var secure = request.isSecure();
final boolean isTokenRequest = "/metadata/identity/oauth2/token".equals(request.getRequestURI());
final boolean unsecureTokenRequest = isTokenRequest && !secure;
final boolean secureVaultRequest = !isTokenRequest && secure;
if (unsecureTokenRequest || secureVaultRequest) {
filterChain.doFilter(request, response);
} else {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.github.nagyesta.lowkeyvault.model;

import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotNull;
import lombok.NonNull;
import org.springframework.util.Assert;

import java.net.URI;
import java.time.Instant;

public record TokenResponse(
@NonNull @JsonProperty("resource") URI resource,
@NonNull @JsonProperty("access_token") String accessToken,
@NonNull @JsonProperty("refresh_token") String refreshToken,
@JsonProperty("expires_in") long expiresIn,
@JsonProperty("expires_on") long expiresOn,
@JsonProperty("tokenType") int tokenType) {

private static final long EXPIRES_IN = 48 * 3600L;
private static final String TOKEN = "dummy";

public TokenResponse(@NotNull final URI resource) {
this(resource, TOKEN, TOKEN, EXPIRES_IN, Instant.now().plusSeconds(EXPIRES_IN).getEpochSecond(), 1);
Assert.hasText(resource.toString(), "Resource must not be empty");
}
}
1 change: 1 addition & 0 deletions lowkey-vault-app/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ application.formatted-version=\\ (v${version})
# suppress inspection "SpringBootApplicationProperties"
application.title=Lowkey Vault
#
app.token.port=8080
server.port=8443
server.ssl.ciphers=TLS_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
server.ssl.protocol=TLS
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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.HttpStatus;
import org.springframework.http.ResponseEntity;

import java.net.URI;

class TokenControllerTest {

@Test
void testGetShouldReturnTokenWhenCalled() {
//given
final ManagedIdentityTokenController underTest = new ManagedIdentityTokenController();
final URI resource = URI.create("https://localhost:8443/");

//when
final ResponseEntity<TokenResponse> 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());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.github.nagyesta.lowkeyvault.filter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.junit.jupiter.api.Test;

import java.io.IOException;

import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.*;

class PortSeparationFilterTest {

@Test
void testDoFilterInternalShouldCallChainWhenInsecureAndTokenRequest() throws ServletException, IOException {
//given
final PortSeparationFilter underTest = new PortSeparationFilter();
final HttpServletRequest request = mock(HttpServletRequest.class);
final HttpServletResponse response = mock(HttpServletResponse.class);
final FilterChain chain = mock(FilterChain.class);
when(request.isSecure()).thenReturn(false);
when(request.getRequestURI()).thenReturn("/metadata/identity/oauth2/token");

//when
underTest.doFilterInternal(request, response, chain);

//then
verify(chain).doFilter(same(request), same(response));
}

@Test
void testDoFilterInternalShouldCallChainWhenSecureAndNotTokenRequest() throws ServletException, IOException {
//given
final PortSeparationFilter underTest = new PortSeparationFilter();
final HttpServletRequest request = mock(HttpServletRequest.class);
final HttpServletResponse response = mock(HttpServletResponse.class);
final FilterChain chain = mock(FilterChain.class);
when(request.isSecure()).thenReturn(true);
when(request.getRequestURI()).thenReturn("/ping");

//when
underTest.doFilterInternal(request, response, chain);

//then
verify(chain).doFilter(same(request), same(response));
}

@Test
void testDoFilterInternalShouldReturnNotFoundWhenSecureAndTokenRequest() throws ServletException, IOException {
//given
final PortSeparationFilter underTest = new PortSeparationFilter();
final HttpServletRequest request = mock(HttpServletRequest.class);
final HttpServletResponse response = mock(HttpServletResponse.class);
final FilterChain chain = mock(FilterChain.class);
when(request.isSecure()).thenReturn(true);
when(request.getRequestURI()).thenReturn("/metadata/identity/oauth2/token");

//when
underTest.doFilterInternal(request, response, chain);

//then
verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
}

@Test
void testDoFilterInternalShouldReturnNotFoundWhenInsecureAndNotTokenRequest() throws ServletException, IOException {
//given
final PortSeparationFilter underTest = new PortSeparationFilter();
final HttpServletRequest request = mock(HttpServletRequest.class);
final HttpServletResponse response = mock(HttpServletResponse.class);
final FilterChain chain = mock(FilterChain.class);
when(request.isSecure()).thenReturn(false);
when(request.getRequestURI()).thenReturn("/ping");

//when
underTest.doFilterInternal(request, response, chain);

//then
verify(response).sendError(HttpServletResponse.SC_NOT_FOUND);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.github.nagyesta.lowkeyvault.model;

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 java.net.URI;
import java.time.Instant;
import java.util.stream.Stream;

class TokenResponseTest {

private static final int EXPECTED_EXPIRY = 48 * 3600;
private static final long MIN_EXPIRES_ON = Instant.now().plusSeconds(EXPECTED_EXPIRY).getEpochSecond();
private static final int TOKEN_TYPE = 1;
private static final String DUMMY_TOKEN = "dummy";
private static final URI RESOURCE = URI.create("https://localhost:8443/path");

public static Stream<Arguments> nullValuesProvider() {
final URI resource = RESOURCE;
final String token = DUMMY_TOKEN;
final long expiresIn = EXPECTED_EXPIRY;
final long expiresOn = MIN_EXPIRES_ON;
final int tokenType = TOKEN_TYPE;

return Stream.of(
Arguments.of(null, token, token, expiresIn, expiresOn, tokenType),
Arguments.of(resource, null, token, expiresIn, expiresOn, tokenType),
Arguments.of(resource, token, null, expiresIn, expiresOn, tokenType)
);
}

@Test
void testConstructorShouldThrowExceptionWhenCalledWithNull() {
//given

//when
Assertions.assertThrows(IllegalArgumentException.class, () -> new TokenResponse(null));

//then + expected
}

@Test
void testConstructorShouldThrowExceptionWhenCalledWithEmptyResource() {
//given
final URI resource = URI.create("");

//when
Assertions.assertThrows(IllegalArgumentException.class, () -> new TokenResponse(resource));

//then + expected
}

@ParameterizedTest
@MethodSource("nullValuesProvider")
void testConstructorShouldThrowExceptionWhenCalledWithNulls(
final URI resource, final String accessToken, final String refreshToken,
final long expiresIn, final long expiresOn, final int tokenType) {
//given

//when
Assertions.assertThrows(IllegalArgumentException.class,
() -> new TokenResponse(resource, accessToken, refreshToken, expiresIn, expiresOn, tokenType));

//then + expected
}

@Test
void testConstructorShouldReturnNonExpiredTokenValidForTheProvidedResourceWhenCalledWithValidResource() {
//given

//when
final TokenResponse actual = new TokenResponse(RESOURCE);

//then
Assertions.assertEquals(RESOURCE, actual.resource());
Assertions.assertEquals(DUMMY_TOKEN, actual.accessToken());
Assertions.assertEquals(DUMMY_TOKEN, actual.refreshToken());
Assertions.assertEquals(EXPECTED_EXPIRY, actual.expiresIn());
Assertions.assertTrue(MIN_EXPIRES_ON <= actual.expiresOn());
Assertions.assertEquals(TOKEN_TYPE, actual.tokenType());
}
}
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
LOWKEY_DEBUG_REQUEST_LOG=true
app.token.port=8080
13 changes: 13 additions & 0 deletions lowkey-vault-docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ export LOWKEY_ARGS="--server.port=8444"
docker run --rm --name lowkey -e LOWKEY_ARGS -d -p 8444:8444 nagyesta/lowkey-vault:<version>
```

### Using simulated Managed Identity

In case you want to rely on the built-in simulated Managed Identity token endpoint, you must make sure
to forward the relevant `8080` HTTP only port as well. The host port you are using can be anything you
would like to use in this case. This will make the `/metadata/identity/oauth2/token` endpoint available.
Please check the [example projects](../README.md#example-projects) to see how you can use the provided
endpoint.

```shell
docker run --rm --name lowkey -d -p 8080:8080 -p 8444:8444 nagyesta/lowkey-vault:<version>
```


### External configuration

Since Lowkey Vault is a Spring Boot application, the default mechanism for Spring Boot external
Expand Down
2 changes: 1 addition & 1 deletion lowkey-vault-docker/src/docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
FROM eclipse-temurin:17.0.11_9-jre-alpine@sha256:ad9223070abcf5716e98296a98c371368810deb36197b75f3a7b74815185c5e3
LABEL maintainer="nagyesta@gmail.com"
EXPOSE 8443:8443
EXPOSE 8080 8443
ADD lowkey-vault.jar /lowkey-vault.jar
RUN \
addgroup -S lowkey && adduser -S lowkey -G lowkey && \
Expand Down
Loading

0 comments on commit de867f8

Please sign in to comment.