diff --git a/spring-vault-core/src/main/java/org/springframework/vault/authentication/AzureMsiAuthentication.java b/spring-vault-core/src/main/java/org/springframework/vault/authentication/AzureMsiAuthentication.java new file mode 100644 index 00000000..93e343cf --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/authentication/AzureMsiAuthentication.java @@ -0,0 +1,232 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.vault.authentication; + +import java.util.LinkedHashMap; +import java.util.Map; + +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.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.vault.VaultException; +import org.springframework.vault.authentication.AuthenticationSteps.HttpRequestBuilder; +import org.springframework.vault.authentication.AuthenticationSteps.Node; +import org.springframework.vault.support.VaultResponse; +import org.springframework.vault.support.VaultToken; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestOperations; + +/** + * Azure MSI (Managed Service Identity) authentication using Azure as trusted third party. + *

+ * Azure MSI authentication uses {@link AzureVmEnvironment} and the MSI OAuth2 token + * (referenced as JWT token in Vault docs) to log into Vault. VM environment and OAuth2 + * token are fetched from the Azure Instance Metadata service. Instances of this class are + * immutable once constructed. + * + * @author Mark Paluch + * @since 2.1 + * @see AzureMsiAuthenticationOptions + * @see RestOperations + * @see Auth Backend: azure + * @link Azure Instance Metadata service + */ +public class AzureMsiAuthentication implements ClientAuthentication { + + private static final Log logger = LogFactory.getLog(AzureMsiAuthentication.class); + + private static final HttpEntity METADATA_HEADERS; + + static { + + HttpHeaders headers = new HttpHeaders(); + headers.add("Metadata", "true"); + METADATA_HEADERS = new HttpEntity<>(headers); + } + + private final AzureMsiAuthenticationOptions options; + + private final RestOperations vaultRestOperations; + + private final RestOperations azureMetadataRestOperations; + + /** + * Create a new {@link AzureMsiAuthentication}. + * + * @param options must not be {@literal null}. + * @param restOperations must not be {@literal null}. + */ + public AzureMsiAuthentication(AzureMsiAuthenticationOptions options, + RestOperations restOperations) { + this(options, restOperations, restOperations); + } + + /** + * Create a new {@link AzureMsiAuthentication} specifying + * {@link AzureMsiAuthenticationOptions}, a Vault and an Azure-Metadata-specific + * {@link RestOperations}. + * + * @param options must not be {@literal null}. + * @param vaultRestOperations must not be {@literal null}. + * @param azureMetadataRestOperations must not be {@literal null}. + */ + public AzureMsiAuthentication(AzureMsiAuthenticationOptions options, + RestOperations vaultRestOperations, RestOperations azureMetadataRestOperations) { + + Assert.notNull(options, "AzureAuthenticationOptions must not be null"); + Assert.notNull(vaultRestOperations, "Vault RestOperations must not be null"); + Assert.notNull(azureMetadataRestOperations, + "Azure Instance Metadata RestOperations must not be null"); + + this.options = options; + this.vaultRestOperations = vaultRestOperations; + this.azureMetadataRestOperations = azureMetadataRestOperations; + } + + /** + * Creates a {@link AuthenticationSteps} for Azure authentication given + * {@link AzureMsiAuthenticationOptions}. + * + * @param options must not be {@literal null}. + * @return {@link AuthenticationSteps} for Azure authentication. + */ + public static AuthenticationSteps createAuthenticationSteps( + AzureMsiAuthenticationOptions options) { + + Assert.notNull(options, "AzureMsiAuthenticationOptions must not be null"); + + return createAuthenticationSteps(options, options.getVmEnvironment()); + } + + protected static AuthenticationSteps createAuthenticationSteps( + AzureMsiAuthenticationOptions options, + @Nullable AzureVmEnvironment environment) { + + Node msiToken = AuthenticationSteps.fromHttpRequest( + HttpRequestBuilder.get(options.getIdentityTokenServiceUri()) + .with(METADATA_HEADERS).as(Map.class)) // + .map(token -> (String) token.get("access_token")); + + Node environmentSteps; + + if (environment == null) { + + environmentSteps = AuthenticationSteps.fromHttpRequest( + HttpRequestBuilder.get(options.getInstanceMetadataServiceUri()) + .with(METADATA_HEADERS).as(Map.class)) // + .map(AzureMsiAuthentication::toAzureVmEnvironment); + } + else { + environmentSteps = AuthenticationSteps.fromSupplier(() -> environment); + } + + return environmentSteps + .zipWith(msiToken) + .map(tuple -> getAzureLogin(options.getRole(), tuple.getLeft(), + tuple.getRight())) // + .login("auth/{mount}/login", options.getPath()); + } + + @Override + public VaultToken login() throws VaultException { + return createTokenUsingAzureMsiCompute(); + } + + @SuppressWarnings("unchecked") + private VaultToken createTokenUsingAzureMsiCompute() { + + Map login = getAzureLogin(options.getRole(), getVmEnvironment(), + getAccessToken()); + + try { + + VaultResponse response = this.vaultRestOperations.postForObject( + "auth/{mount}/login", login, VaultResponse.class, options.getPath()); + + Assert.state(response != null && response.getAuth() != null, + "Auth field must not be null"); + + if (logger.isDebugEnabled()) { + logger.debug("Login successful using Azure authentication"); + } + + return LoginTokenUtil.from(response.getAuth()); + } + catch (RestClientException e) { + throw VaultLoginException.create("Azure", e); + } + } + + private static Map getAzureLogin(String role, + AzureVmEnvironment vmEnvironment, String jwt) { + + Map loginBody = new LinkedHashMap<>(); + loginBody.put("resource_group_name", vmEnvironment.getResourceGroupName()); + loginBody.put("vm_name", vmEnvironment.getVmName()); + loginBody.put("subscription_id", vmEnvironment.getSubscriptionId()); + loginBody.put("jwt", jwt); + loginBody.put("role", role); + + return loginBody; + } + + @SuppressWarnings("unchecked") + private String getAccessToken() { + + ResponseEntity response = this.azureMetadataRestOperations.exchange( + options.getIdentityTokenServiceUri(), HttpMethod.GET, METADATA_HEADERS, + Map.class); + + return (String) response.getBody().get("access_token"); + } + + private AzureVmEnvironment getVmEnvironment() { + + AzureVmEnvironment vmEnvironment = options.getVmEnvironment(); + + return vmEnvironment != null ? vmEnvironment : fetchAzureVmEnvironment(); + } + + private AzureVmEnvironment fetchAzureVmEnvironment() { + + ResponseEntity response = this.azureMetadataRestOperations.exchange( + options.getInstanceMetadataServiceUri(), HttpMethod.GET, + METADATA_HEADERS, Map.class); + + return toAzureVmEnvironment(response.getBody()); + } + + @SuppressWarnings("unchecked") + private static AzureVmEnvironment toAzureVmEnvironment( + Map instanceMetadata) { + + Map compute = (Map) instanceMetadata.get("compute"); + + String subscriptionId = compute.get("subscriptionId"); + String vmName = compute.get("name"); + String resourceGroupName = compute.get("resourceGroupName"); + + return new AzureVmEnvironment(subscriptionId, resourceGroupName, vmName); + } +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/authentication/AzureMsiAuthenticationOptions.java b/spring-vault-core/src/main/java/org/springframework/vault/authentication/AzureMsiAuthenticationOptions.java new file mode 100644 index 00000000..3df7e846 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/authentication/AzureMsiAuthenticationOptions.java @@ -0,0 +1,241 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.vault.authentication; + +import java.net.URI; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Authentication options for {@link AzureMsiAuthentication}. + *

+ * Authentication options provide the path, role, an optional {@link AzureVmEnvironment}, + * and instance metadata/OAuth2 token URIs. {@link AzureMsiAuthenticationOptions} can be + * constructed using {@link #builder()}. Instances of this class are immutable once + * constructed. + * + * @author Mark Paluch + * @since 2.1 + * @see AzureMsiAuthenticationOptions + * @see #builder() + */ +public class AzureMsiAuthenticationOptions { + + public static final String DEFAULT_AZURE_AUTHENTICATION_PATH = "azure"; + + public static final URI DEFAULT_INSTANCE_METADATA_SERVICE_URI = URI + .create("http://169.254.169.254/metadata/instance?api-version=2017-08-01"); + + public static final URI DEFAULT_IDENTITY_TOKEN_SERVICE_URI = URI + .create("http://169.254.169.254/metadata/identity/oauth2/token?resource=https://vault.hashicorp.com&api-version=2018-02-01"); + + /** + * Path of the azure authentication backend mount. + */ + private final String path; + + /** + * Name of the role against which the login is being attempted. + */ + private final String role; + + /* + * {@link URI} to the instance metadata endpoint. + */ + private final URI instanceMetadataServiceUri; + + /* + * {@link URI} to the token service for the managed identity. + */ + private final URI identityTokenServiceUri; + + /** + * Optional {@link AzureVmEnvironment}. + */ + @Nullable + private final AzureVmEnvironment vmEnvironment; + + private AzureMsiAuthenticationOptions(String path, String role, + URI instanceMetadataServiceUri, URI identityTokenServiceUri, + @Nullable AzureVmEnvironment vmEnvironment) { + + this.path = path; + this.role = role; + this.instanceMetadataServiceUri = instanceMetadataServiceUri; + this.identityTokenServiceUri = identityTokenServiceUri; + this.vmEnvironment = vmEnvironment; + } + + /** + * @return a new {@link AzureMsiAuthenticationOptionsBuilder}. + */ + public static AzureMsiAuthenticationOptionsBuilder builder() { + return new AzureMsiAuthenticationOptionsBuilder(); + } + + /** + * @return the path of the azure authentication backend mount. + */ + public String getPath() { + return path; + } + + /** + * @return the role against which the login is being attempted. + */ + public String getRole() { + return role; + } + + /** + * @return the {@link AzureVmEnvironment}. If {@literal null}, the environment is + * retrieved from the {@link #getInstanceMetadataServiceUri() VM instance metadata + * service}. + */ + @Nullable + public AzureVmEnvironment getVmEnvironment() { + return vmEnvironment; + } + + /** + * @return {@link URI} to the instance metadata endpoint. + */ + public URI getInstanceMetadataServiceUri() { + return instanceMetadataServiceUri; + } + + /** + * @return {@link URI} to the token service for the managed identity. + */ + public URI getIdentityTokenServiceUri() { + return identityTokenServiceUri; + } + + /** + * Builder for {@link AzureMsiAuthenticationOptions}. + */ + public static class AzureMsiAuthenticationOptionsBuilder { + + private String path = DEFAULT_AZURE_AUTHENTICATION_PATH; + + @Nullable + private String role; + + @Nullable + private AzureVmEnvironment vmEnvironment; + + private URI instanceMetadataServiceUri = DEFAULT_INSTANCE_METADATA_SERVICE_URI; + + private URI identityTokenServiceUri = DEFAULT_IDENTITY_TOKEN_SERVICE_URI; + + AzureMsiAuthenticationOptionsBuilder() { + } + + /** + * Configure the mount path, defaults to {@literal azure}. + * + * @param path must not be empty or {@literal null}. + * @return {@code this} {@link AzureMsiAuthenticationOptionsBuilder}. + */ + public AzureMsiAuthenticationOptionsBuilder path(String path) { + + Assert.hasText(path, "Path must not be empty"); + + this.path = path; + return this; + } + + /** + * Configure the name of the role against which the login is being attempted. + * + * @param role must not be empty or {@literal null}. + * @return {@code this} {@link AzureMsiAuthenticationOptionsBuilder}. + */ + public AzureMsiAuthenticationOptionsBuilder role(String role) { + + Assert.hasText(role, "Role must not be null or empty"); + + this.role = role; + return this; + } + + /** + * Configure a VM environment (subscriptionId, resource group name, VM name). + * Environment details are passed to Vault as login body. If left unconfigured, + * {@link AzureMsiAuthentication} looks up the details from the instance metadata + * service. + * + * @param vmEnvironment must not be {@literal null}. + * @return {@code this} {@link AzureMsiAuthenticationOptionsBuilder}. + */ + public AzureMsiAuthenticationOptionsBuilder vmEnvironment( + AzureVmEnvironment vmEnvironment) { + + Assert.notNull(vmEnvironment, "AzureVmEnvironment must not be null"); + + this.vmEnvironment = vmEnvironment; + return this; + } + + /** + * Configure the instance metadata {@link URI}. + * + * @param instanceMetadataServiceUri must not be {@literal null}. + * @return {@code this} {@link AzureMsiAuthenticationOptionsBuilder}. + * @see #DEFAULT_IDENTITY_TOKEN_SERVICE_URI + */ + public AzureMsiAuthenticationOptionsBuilder instanceMetadataUri( + URI instanceMetadataServiceUri) { + + Assert.notNull(identityTokenServiceUri, + "Instance metadata service URI must not be null"); + + this.instanceMetadataServiceUri = instanceMetadataServiceUri; + return this; + } + + /** + * Configure the managed identity service token {@link URI}. + * + * @param identityTokenServiceUri must not be {@literal null}. + * @return {@code this} {@link AzureMsiAuthenticationOptionsBuilder}. + * @see #DEFAULT_IDENTITY_TOKEN_SERVICE_URI + */ + public AzureMsiAuthenticationOptionsBuilder identityTokenServiceUri( + URI identityTokenServiceUri) { + + Assert.notNull(identityTokenServiceUri, + "Identity token service URI must not be null"); + + this.identityTokenServiceUri = identityTokenServiceUri; + return this; + } + + /** + * Build a new {@link AzureMsiAuthenticationOptions} instance. + * + * @return a new {@link AzureMsiAuthenticationOptions}. + */ + public AzureMsiAuthenticationOptions build() { + + Assert.hasText(role, "Role must not be null or empty"); + + return new AzureMsiAuthenticationOptions(path, role, + instanceMetadataServiceUri, identityTokenServiceUri, vmEnvironment); + } + } +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/authentication/AzureVmEnvironment.java b/spring-vault-core/src/main/java/org/springframework/vault/authentication/AzureVmEnvironment.java new file mode 100644 index 00000000..201309ae --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/authentication/AzureVmEnvironment.java @@ -0,0 +1,70 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.vault.authentication; + +import org.springframework.util.Assert; + +/** + * Value object representing a VM environment consisting of the subscription Id, the + * resource group name and the VM name. + * + * @author Mark Paluch + * @since 2.1 + * @see AzureMsiAuthentication + * @see AzureMsiAuthenticationOptions + * @link Azure Instance Metadata service + */ +public class AzureVmEnvironment { + + private final String subscriptionId; + + private final String resourceGroupName; + + private final String vmName; + + /** + * Creates a new {@link AzureVmEnvironment}. + * + * @param subscriptionId must not be {@literal null}. + * @param resourceGroupName must not be {@literal null}. + * @param vmName must not be {@literal null}. + */ + public AzureVmEnvironment(String subscriptionId, String resourceGroupName, + String vmName) { + + Assert.notNull(subscriptionId, "SubscriptionId must not be null"); + Assert.notNull(resourceGroupName, "Resource group name must not be null"); + Assert.notNull(vmName, "VM name must not be null"); + + this.subscriptionId = subscriptionId; + this.resourceGroupName = resourceGroupName; + this.vmName = vmName; + } + + public String getSubscriptionId() { + return subscriptionId; + } + + public String getResourceGroupName() { + return resourceGroupName; + } + + public String getVmName() { + return vmName; + } +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/config/EnvironmentVaultConfiguration.java b/spring-vault-core/src/main/java/org/springframework/vault/config/EnvironmentVaultConfiguration.java index b17dc808..0a167073 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/config/EnvironmentVaultConfiguration.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/config/EnvironmentVaultConfiguration.java @@ -32,6 +32,8 @@ import org.springframework.vault.authentication.AppRoleAuthentication; import org.springframework.vault.authentication.AppRoleAuthenticationOptions; import org.springframework.vault.authentication.AwsEc2Authentication; import org.springframework.vault.authentication.AwsEc2AuthenticationOptions; +import org.springframework.vault.authentication.AzureMsiAuthentication; +import org.springframework.vault.authentication.AzureMsiAuthenticationOptions; import org.springframework.vault.authentication.ClientAuthentication; import org.springframework.vault.authentication.ClientCertificateAuthentication; import org.springframework.vault.authentication.CubbyholeAuthentication; @@ -96,7 +98,7 @@ import org.springframework.web.client.RestOperations; * *

  • Authentication method: {@code vault.authentication} (defaults to {@literal TOKEN}, * supported authentication methods are: - * {@literal TOKEN, APPID, APPROLE, AWS_EC2, CERT, CUBBYHOLE})
  • + * {@literal TOKEN, APPID, APPROLE, AZURE, AWS_EC2, CERT, CUBBYHOLE}) *
  • Token authentication * + *
  • Azure MSI authentication + * *
  • Client Certificate authentication *