From dac9e36c3738f9e9eb150b51f83ee89a2ab12fef Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Thu, 6 Jun 2024 09:51:58 +0200 Subject: [PATCH] Add support for IMDSv2 on EC2 instances. Closes gh-865 --- .../authentication/AuthenticationSteps.java | 21 +++ .../AuthenticationStepsExecutor.java | 10 +- .../AuthenticationStepsOperator.java | 16 +-- .../authentication/AwsEc2Authentication.java | 79 ++++++++++- .../AwsEc2AuthenticationOptions.java | 131 +++++++++++++++++- .../AuthenticationStepsOperatorUnitTests.java | 35 ++++- .../AwsEc2AuthenticationUnitTests.java | 65 ++++++++- .../ROOT/pages/vault/authentication.adoc | 3 + 8 files changed, 330 insertions(+), 30 deletions(-) diff --git a/spring-vault-core/src/main/java/org/springframework/vault/authentication/AuthenticationSteps.java b/spring-vault-core/src/main/java/org/springframework/vault/authentication/AuthenticationSteps.java index 178d6845..200cc4af 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/authentication/AuthenticationSteps.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/authentication/AuthenticationSteps.java @@ -342,6 +342,27 @@ public class AuthenticationSteps { return new HttpRequestBuilder(HttpMethod.POST, uri); } + /** + * Builder entry point to {@code PUT} to {@code uriTemplate}. + * @param uriTemplate must not be {@literal null} or empty. + * @param uriVariables the variables to expand the template. + * @return a new {@link HttpRequestBuilder}. + * @since 3.2 + */ + public static HttpRequestBuilder put(String uriTemplate, String... uriVariables) { + return new HttpRequestBuilder(HttpMethod.PUT, uriTemplate, uriVariables); + } + + /** + * Builder entry point to {@code PUT} to {@code uri}. + * @param uri must not be {@literal null}. + * @return a new {@link HttpRequestBuilder}. + * @since 3.2 + */ + public static HttpRequestBuilder put(URI uri) { + return new HttpRequestBuilder(HttpMethod.PUT, uri); + } + /** * Builder entry point to use {@link HttpMethod} for {@code uriTemplate}. * @param uriTemplate must not be {@literal null} or empty. diff --git a/spring-vault-core/src/main/java/org/springframework/vault/authentication/AuthenticationStepsExecutor.java b/spring-vault-core/src/main/java/org/springframework/vault/authentication/AuthenticationStepsExecutor.java index 9a40aa01..49bc4634 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/authentication/AuthenticationStepsExecutor.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/authentication/AuthenticationStepsExecutor.java @@ -19,6 +19,7 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -48,7 +49,7 @@ import org.springframework.web.client.RestOperations; */ public class AuthenticationStepsExecutor implements ClientAuthentication { - private static final Log logger = LogFactory.getLog(AppIdAuthentication.class); + private static final Log logger = LogFactory.getLog(AuthenticationStepsExecutor.class); private final AuthenticationSteps chain; @@ -166,9 +167,14 @@ public class AuthenticationStepsExecutor implements ClientAuthentication { } - private static HttpEntity getEntity(@Nullable HttpEntity entity, @Nullable Object state) { + static HttpEntity getEntity(@Nullable HttpEntity entity, @Nullable Object state) { if (entity == null) { + + if (state instanceof HttpHeaders headers) { + return new HttpEntity<>(headers); + } + return state == null ? HttpEntity.EMPTY : new HttpEntity<>(state); } diff --git a/spring-vault-core/src/main/java/org/springframework/vault/authentication/AuthenticationStepsOperator.java b/spring-vault-core/src/main/java/org/springframework/vault/authentication/AuthenticationStepsOperator.java index 5bf9919c..9dd9b595 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/authentication/AuthenticationStepsOperator.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/authentication/AuthenticationStepsOperator.java @@ -29,6 +29,7 @@ import org.springframework.core.io.buffer.DataBufferFactory; import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.vault.VaultException; @@ -161,7 +162,7 @@ public class AuthenticationStepsOperator implements VaultTokenSupplier { private Mono doHttpRequest(HttpRequestNode step, Object state) { HttpRequest definition = step.getDefinition(); - HttpEntity entity = getEntity(definition.getEntity(), state); + HttpEntity entity = AuthenticationStepsExecutor.getEntity(definition.getEntity(), state); RequestBodySpec spec; if (definition.getUri() == null) { @@ -184,19 +185,6 @@ public class AuthenticationStepsOperator implements VaultTokenSupplier { return spec.retrieve().bodyToMono(definition.getResponseType()); } - private static HttpEntity getEntity(@Nullable HttpEntity entity, @Nullable Object state) { - - if (entity == null) { - return state == null ? HttpEntity.EMPTY : new HttpEntity<>(state); - } - - if (entity.getBody() == null && state != null) { - return new HttpEntity<>(state, entity.getHeaders()); - } - - return entity; - } - private static Object doMapStep(MapStep o, Object state) { return o.apply(state); } diff --git a/spring-vault-core/src/main/java/org/springframework/vault/authentication/AwsEc2Authentication.java b/spring-vault-core/src/main/java/org/springframework/vault/authentication/AwsEc2Authentication.java index 53a9c838..2b289c96 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/authentication/AwsEc2Authentication.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/authentication/AwsEc2Authentication.java @@ -16,6 +16,7 @@ package org.springframework.vault.authentication; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; import java.util.concurrent.atomic.AtomicReference; @@ -24,12 +25,17 @@ import java.util.function.Supplier; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; import org.springframework.util.Assert; import org.springframework.util.StringUtils; import org.springframework.vault.VaultException; import org.springframework.vault.authentication.AuthenticationSteps.HttpRequestBuilder; import org.springframework.vault.support.VaultResponse; import org.springframework.vault.support.VaultToken; +import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestOperations; @@ -52,6 +58,10 @@ public class AwsEc2Authentication implements ClientAuthentication, Authenticatio private static final char[] EMPTY = new char[0]; + private static final String METADATA_TOKEN_TTL_HEADER = "X-aws-ec2-metadata-token-ttl-seconds"; + + private static final String METADATA_TOKEN_HEADER = "X-aws-ec2-metadata-token"; + private final AwsEc2AuthenticationOptions options; private final RestOperations vaultRestOperations; @@ -107,13 +117,34 @@ public class AwsEc2Authentication implements ClientAuthentication, Authenticatio protected static AuthenticationSteps createAuthenticationSteps(AwsEc2AuthenticationOptions options, AtomicReference nonce, Supplier nonceSupplier) { - return AuthenticationSteps - .fromHttpRequest(HttpRequestBuilder.get(options.getIdentityDocumentUri().toString()).as(String.class)) // + AuthenticationSteps.HttpRequest identityRequest = HttpRequestBuilder + .get(options.getIdentityDocumentUri().toString()) + .as(String.class); + AuthenticationSteps.Node identity; + + if (options.getVersion() == AwsEc2AuthenticationOptions.InstanceMetadataServiceVersion.V2) { + + identity = AuthenticationSteps + .fromHttpRequest(HttpRequestBuilder.put(options.getMetadataTokenRequestUri()) + .with(createTokenRequestHeaders(options)) + .as(String.class)) + .map(it -> { + HttpHeaders headers = new HttpHeaders(); + headers.add(METADATA_TOKEN_HEADER, it); + return headers; + }) + .request(identityRequest); + } + else { + identity = AuthenticationSteps.fromHttpRequest(identityRequest); + } + + return identity // .map(pkcs7 -> pkcs7.replaceAll("\\r", "")) // .map(pkcs7 -> pkcs7.replaceAll("\\n", "")) // .map(pkcs7 -> { - Map login = new HashMap<>(); + Map login = new LinkedHashMap<>(); if (StringUtils.hasText(options.getRole())) { login.put("role", options.getRole()); @@ -175,6 +206,11 @@ public class AwsEc2Authentication implements ClientAuthentication, Authenticatio protected Map getEc2Login() { Map login = new HashMap<>(); + HttpHeaders headers = new HttpHeaders(); + + if (options.getVersion() == AwsEc2AuthenticationOptions.InstanceMetadataServiceVersion.V2) { + headers.add(METADATA_TOKEN_HEADER, createIMDSv2Token()); + } if (StringUtils.hasText(this.options.getRole())) { login.put("role", this.options.getRole()); @@ -187,8 +223,15 @@ public class AwsEc2Authentication implements ClientAuthentication, Authenticatio login.put("nonce", new String(this.nonce.get())); try { - String pkcs7 = this.awsMetadataRestOperations.getForObject(this.options.getIdentityDocumentUri(), - String.class); + + HttpEntity entity = new HttpEntity<>(headers); + ResponseEntity exchange = this.awsMetadataRestOperations + .exchange(this.options.getIdentityDocumentUri(), HttpMethod.GET, entity, String.class); + if (!exchange.getStatusCode().is2xxSuccessful()) { + throw new HttpClientErrorException(exchange.getStatusCode()); + } + + String pkcs7 = exchange.getBody(); if (StringUtils.hasText(pkcs7)) { login.put("pkcs7", pkcs7.replaceAll("\\r", "").replaceAll("\\n", "")); } @@ -201,6 +244,26 @@ public class AwsEc2Authentication implements ClientAuthentication, Authenticatio } } + private String createIMDSv2Token() { + + try { + + HttpEntity entity = new HttpEntity<>(createTokenRequestHeaders(this.options)); + + ResponseEntity exchange = this.awsMetadataRestOperations + .exchange(this.options.getMetadataTokenRequestUri(), HttpMethod.PUT, entity, String.class); + if (!exchange.getStatusCode().is2xxSuccessful()) { + throw new HttpClientErrorException(exchange.getStatusCode()); + } + + return exchange.getBody(); + } + catch (RestClientException e) { + throw new VaultLoginException( + String.format("Cannot obtain IMDSv2 Token from %s", this.options.getMetadataTokenRequestUri()), e); + } + } + protected char[] createNonce() { return doCreateNonce(this.options); } @@ -209,4 +272,10 @@ public class AwsEc2Authentication implements ClientAuthentication, Authenticatio return options.getNonce().getValue(); } + private static HttpHeaders createTokenRequestHeaders(AwsEc2AuthenticationOptions options) { + HttpHeaders tokenRequestHeaders = new HttpHeaders(); + tokenRequestHeaders.add(METADATA_TOKEN_TTL_HEADER, String.valueOf(options.getMetadataTokenTtl().toSeconds())); + return tokenRequestHeaders; + } + } diff --git a/spring-vault-core/src/main/java/org/springframework/vault/authentication/AwsEc2AuthenticationOptions.java b/spring-vault-core/src/main/java/org/springframework/vault/authentication/AwsEc2AuthenticationOptions.java index b23105d7..8839acbb 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/authentication/AwsEc2AuthenticationOptions.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/authentication/AwsEc2AuthenticationOptions.java @@ -16,6 +16,7 @@ package org.springframework.vault.authentication; import java.net.URI; +import java.time.Duration; import java.util.Arrays; import java.util.UUID; @@ -38,6 +39,11 @@ public class AwsEc2AuthenticationOptions { public static final URI DEFAULT_PKCS7_IDENTITY_DOCUMENT_URI = URI .create("http://169.254.169.254/latest/dynamic/instance-identity/pkcs7"); + /** + * @since 3.2 + */ + public static final URI DEFAULT_IMDSV2_TOKEN_URI = URI.create("http://169.254.169.254/latest/api/token"); + public static final String DEFAULT_AWS_AUTHENTICATION_PATH = "aws-ec2"; /** @@ -68,16 +74,39 @@ public class AwsEc2AuthenticationOptions { */ private final Nonce nonce; + /** + * IMDSv2 token TTL. + * @since 3.2 + */ + private final Duration metadataTokenTtl; + + /** + * {@link URI} to request a token for the AWS EC2 Instance Metadata service v2. + * @since 3.2 + */ + private final URI metadataTokenRequestUri; + + /** + * Metadata service version. + * @since 3.2 + */ + private final InstanceMetadataServiceVersion version; + private AwsEc2AuthenticationOptions() { - this(DEFAULT_AWS_AUTHENTICATION_PATH, DEFAULT_PKCS7_IDENTITY_DOCUMENT_URI, "", Nonce.generated()); + this(DEFAULT_AWS_AUTHENTICATION_PATH, DEFAULT_PKCS7_IDENTITY_DOCUMENT_URI, "", Nonce.generated(), + Duration.ofMinutes(1), DEFAULT_IMDSV2_TOKEN_URI, InstanceMetadataServiceVersion.V1); } - private AwsEc2AuthenticationOptions(String path, URI identityDocumentUri, @Nullable String role, Nonce nonce) { + private AwsEc2AuthenticationOptions(String path, URI identityDocumentUri, @Nullable String role, Nonce nonce, + Duration metadataTokenTtl, URI metadataTokenRequestUri, InstanceMetadataServiceVersion version) { this.path = path; this.identityDocumentUri = identityDocumentUri; this.role = role; this.nonce = nonce; + this.metadataTokenTtl = metadataTokenTtl; + this.metadataTokenRequestUri = metadataTokenRequestUri; + this.version = version; } /** @@ -116,6 +145,30 @@ public class AwsEc2AuthenticationOptions { return this.nonce; } + /** + * @return the configured {@link InstanceMetadataServiceVersion}. + * @since 3.2 + */ + public InstanceMetadataServiceVersion getVersion() { + return version; + } + + /** + * @return the configured IMDSv2 token TTL. + * @since 3.2 + */ + public Duration getMetadataTokenTtl() { + return metadataTokenTtl; + } + + /** + * @return the {@link URI} to the AWS EC2 Metadata Service to obtain IMDSv2 tokens. + * @since 3.2 + */ + public URI getMetadataTokenRequestUri() { + return this.metadataTokenRequestUri; + } + /** * Builder for {@link AwsEc2AuthenticationOptionsBuilder}. */ @@ -130,6 +183,12 @@ public class AwsEc2AuthenticationOptions { private Nonce nonce = Nonce.generated(); + private Duration metadataTokenTtl = Duration.ofMinutes(1); + + private URI metadataTokenRequestUri = DEFAULT_IMDSV2_TOKEN_URI; + + private InstanceMetadataServiceVersion version = InstanceMetadataServiceVersion.V1; + AwsEc2AuthenticationOptionsBuilder() { } @@ -188,6 +247,51 @@ public class AwsEc2AuthenticationOptions { return this; } + /** + * Configure the Instance Service Metadata v2 Token TTL. Defaults to 1 minute. + * @param ttl must not be {@literal null}. + * @return {@code this} {@link AwsEc2AuthenticationOptionsBuilder}. + * @since 3.2 + */ + public AwsEc2AuthenticationOptionsBuilder metadataTokenTtl(Duration ttl) { + + Assert.notNull(ttl, "Duration must not be null"); + Assert.isTrue(!ttl.isNegative() && !ttl.isZero(), "Duration must not be zero or negative"); + + this.metadataTokenTtl = ttl; + return this; + } + + /** + * Configure the Identity Metadata token request {@link URI}. + * @param metadataTokenRequestUri must not be {@literal null}. + * @return {@code this} {@link AwsEc2AuthenticationOptionsBuilder}. + * @since 3.2 + * @see #DEFAULT_IMDSV2_TOKEN_URI + */ + public AwsEc2AuthenticationOptionsBuilder metadataTokenRequestUri(URI metadataTokenRequestUri) { + + Assert.notNull(metadataTokenRequestUri, "Metadata token request URI must not be null"); + + this.metadataTokenRequestUri = metadataTokenRequestUri; + return this; + } + + /** + * Configure the Instance Service Metadata {@link InstanceMetadataServiceVersion + * version}. Defaults to {@link InstanceMetadataServiceVersion#V1}. + * @param version must not be {@literal null}. + * @return {@code this} {@link AwsEc2AuthenticationOptionsBuilder}. + * @since 3.2 + */ + public AwsEc2AuthenticationOptionsBuilder version(InstanceMetadataServiceVersion version) { + + Assert.notNull(version, "Version must not be null"); + + this.version = version; + return this; + } + /** * Build a new {@link AwsEc2AuthenticationOptions} instance. * @return a new {@link AwsEc2AuthenticationOptions}. @@ -196,7 +300,8 @@ public class AwsEc2AuthenticationOptions { Assert.notNull(this.identityDocumentUri, "IdentityDocumentUri must not be null"); - return new AwsEc2AuthenticationOptions(this.path, this.identityDocumentUri, this.role, this.nonce); + return new AwsEc2AuthenticationOptions(this.path, this.identityDocumentUri, this.role, this.nonce, + this.metadataTokenTtl, this.metadataTokenRequestUri, this.version); } } @@ -258,4 +363,24 @@ public class AwsEc2AuthenticationOptions { } + /** + * Enumeration for the Instance metadata service version. + * + * @since 3.2 + */ + public enum InstanceMetadataServiceVersion { + + /** + * Request/Response (default) oriented version 1. + */ + V1, + + /** + * Session-oriented version 2. + */ + + V2; + + } + } diff --git a/spring-vault-core/src/test/java/org/springframework/vault/authentication/AuthenticationStepsOperatorUnitTests.java b/spring-vault-core/src/test/java/org/springframework/vault/authentication/AuthenticationStepsOperatorUnitTests.java index 436c87eb..1573ac5f 100644 --- a/spring-vault-core/src/test/java/org/springframework/vault/authentication/AuthenticationStepsOperatorUnitTests.java +++ b/spring-vault-core/src/test/java/org/springframework/vault/authentication/AuthenticationStepsOperatorUnitTests.java @@ -15,6 +15,9 @@ */ package org.springframework.vault.authentication; +import static org.assertj.core.api.Assertions.*; +import static org.springframework.vault.authentication.AuthenticationSteps.HttpRequestBuilder.*; + import java.io.IOException; import java.io.InputStream; @@ -24,6 +27,7 @@ import reactor.test.StepVerifier; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -37,9 +41,6 @@ import org.springframework.vault.support.VaultResponse; import org.springframework.vault.support.VaultToken; import org.springframework.web.reactive.function.client.WebClient; -import static org.assertj.core.api.Assertions.assertThat; -import static org.springframework.vault.authentication.AuthenticationSteps.HttpRequestBuilder.post; - /** * Unit tests for {@link AuthenticationStepsOperator}. * @@ -123,6 +124,34 @@ class AuthenticationStepsOperatorUnitTests { .verifyComplete(); } + @Test + void headersShouldBeConsideredForHttpRequest() { + + ClientHttpRequest request = new MockClientHttpRequest(HttpMethod.POST, "/auth/cert/login"); + MockClientHttpResponse response = new MockClientHttpResponse(HttpStatus.OK); + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); + response.setBody( + "{" + "\"auth\":{\"client_token\":\"my-token\", \"renewable\": true, \"lease_duration\": 10}" + "}"); + ClientHttpConnector connector = (method, uri, fn) -> { + + return fn.apply(request).doOnSuccess(unused -> { + assertThat(request.getHeaders().get("a")).contains("b"); + }).then(Mono.just(response)); + }; + + WebClient webClient = WebClient.builder().clientConnector(connector).build(); + + HttpHeaders headers = new HttpHeaders(); + headers.add("a", "b"); + + AuthenticationSteps steps = AuthenticationSteps.fromValue(headers) + .login(post("/auth/{path}/login", "cert").as(VaultResponse.class)); + + login(steps, webClient).as(StepVerifier::create) // + .expectNext(VaultToken.of("my-token")) // + .verifyComplete(); + } + @Test void justLoginRequestShouldLogin() { diff --git a/spring-vault-core/src/test/java/org/springframework/vault/authentication/AwsEc2AuthenticationUnitTests.java b/spring-vault-core/src/test/java/org/springframework/vault/authentication/AwsEc2AuthenticationUnitTests.java index c266605d..578e2981 100644 --- a/spring-vault-core/src/test/java/org/springframework/vault/authentication/AwsEc2AuthenticationUnitTests.java +++ b/spring-vault-core/src/test/java/org/springframework/vault/authentication/AwsEc2AuthenticationUnitTests.java @@ -19,12 +19,22 @@ import java.time.Duration; import java.util.Collections; import java.util.Map; +import org.checkerframework.checker.units.qual.A; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.http.client.reactive.ClientHttpRequest; +import org.springframework.mock.http.client.reactive.MockClientHttpRequest; +import org.springframework.mock.http.client.reactive.MockClientHttpResponse; import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder; import org.springframework.vault.VaultException; import org.springframework.vault.authentication.AwsEc2AuthenticationOptions.Nonce; import org.springframework.vault.client.VaultClients; @@ -33,9 +43,7 @@ import org.springframework.web.client.RestTemplate; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath; -import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; -import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; @@ -72,6 +80,57 @@ class AwsEc2AuthenticationUnitTests { assertThat(authentication.getEc2Login()).containsEntry("pkcs7", "Hello, world").containsKey("nonce").hasSize(2); } + @Test + void shouldObtainIdentityDocumentV2() { + + AwsEc2AuthenticationOptions options = AwsEc2AuthenticationOptions.builder() + .version(AwsEc2AuthenticationOptions.InstanceMetadataServiceVersion.V2) + .build(); + + this.mockRest.expect(requestTo("http://169.254.169.254/latest/api/token")) // + .andExpect(method(HttpMethod.PUT)) // + .andExpect(header("X-aws-ec2-metadata-token-ttl-seconds", "60")) // + .andRespond(withSuccess().body("my-token")); + + this.mockRest.expect(requestTo("http://169.254.169.254/latest/dynamic/instance-identity/pkcs7")) // + .andExpect(method(HttpMethod.GET)) // + .andExpect(header("X-aws-ec2-metadata-token", "my-token")) // + .andRespond(withSuccess().body("Hello, world")); + + AwsEc2Authentication authentication = new AwsEc2Authentication(options, this.restTemplate, this.restTemplate); + + assertThat(authentication.getEc2Login()).containsEntry("pkcs7", "Hello, world").containsKey("nonce").hasSize(2); + } + + @Test + void shouldObtainIdentityDocumentWithImperativeAuthenticationStepsV2() { + + AwsEc2AuthenticationOptions options = AwsEc2AuthenticationOptions.builder() + .version(AwsEc2AuthenticationOptions.InstanceMetadataServiceVersion.V2) + .build(); + + this.mockRest.expect(requestTo("http://169.254.169.254/latest/api/token")) // + .andExpect(method(HttpMethod.PUT)) // + .andExpect(header("X-aws-ec2-metadata-token-ttl-seconds", "60")) // + .andRespond(withSuccess().body("my-token")); + + this.mockRest.expect(requestTo("http://169.254.169.254/latest/dynamic/instance-identity/pkcs7")) // + .andExpect(method(HttpMethod.GET)) // + .andExpect(header("X-aws-ec2-metadata-token", "my-token")) // + .andRespond(withSuccess().body("Hello, world")); + + this.mockRest.expect(requestTo("/auth/aws-ec2/login")) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath("$.pkcs7").value("Hello, world")) + .andRespond(withSuccess().contentType(MediaType.APPLICATION_JSON) + .body("{" + "\"auth\":{\"client_token\":\"my-token\", \"lease_duration\":20}" + "}")); + + AuthenticationSteps steps = AwsEc2Authentication.createAuthenticationSteps(options); + AuthenticationStepsExecutor executor = new AuthenticationStepsExecutor(steps, this.restTemplate); + + assertThat(executor.login()).isEqualTo(LoginToken.of("my-token")); + } + @Test void shouldCleanUpIdentityResponse() { diff --git a/src/main/antora/modules/ROOT/pages/vault/authentication.adoc b/src/main/antora/modules/ROOT/pages/vault/authentication.adoc index 03f096d9..3b9f3439 100644 --- a/src/main/antora/modules/ROOT/pages/vault/authentication.adoc +++ b/src/main/antora/modules/ROOT/pages/vault/authentication.adoc @@ -306,6 +306,9 @@ further investigation. The nonce is kept in memory and is lost during application restart. +Since Spring Vault 3.2, AWS-EC2 authentication supports request/response +(IMDSv1) metadata retrieval and the session-based variant (IMDSv2). + AWS-EC2 authentication roles are optional and default to the AMI. You can configure the authentication role by setting it in `AwsEc2AuthenticationOptions`.