From 1367275b661649efa3c1a25cd9d3aa32eeff838d Mon Sep 17 00:00:00 2001 From: Nanne Baars Date: Wed, 28 Jun 2023 11:15:51 +0200 Subject: [PATCH] Support JWT Authentication Closes gh-689 Original pull request: gh-802 --- spring-vault-core/pom.xml | 7 + .../authentication/JwtAuthentication.java | 108 ++++++++++++ .../JwtAuthenticationOptions.java | 157 ++++++++++++++++++ .../JwtAuthenticationIntegrationTest.java | 127 ++++++++++++++ .../asciidoc/reference/authentication.adoc | 36 ++++ 5 files changed, 435 insertions(+) create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/authentication/JwtAuthentication.java create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/authentication/JwtAuthenticationOptions.java create mode 100644 spring-vault-core/src/test/java/org/springframework/vault/authentication/JwtAuthenticationIntegrationTest.java diff --git a/spring-vault-core/pom.xml b/spring-vault-core/pom.xml index 7d4634e6..608e24c1 100644 --- a/spring-vault-core/pom.xml +++ b/spring-vault-core/pom.xml @@ -314,6 +314,13 @@ test + + com.nimbusds + nimbus-jose-jwt + 9.30.2 + test + + diff --git a/spring-vault-core/src/main/java/org/springframework/vault/authentication/JwtAuthentication.java b/spring-vault-core/src/main/java/org/springframework/vault/authentication/JwtAuthentication.java new file mode 100644 index 00000000..545c42c4 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/authentication/JwtAuthentication.java @@ -0,0 +1,108 @@ +/* + * Copyright 2017-2023 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 + * + * https://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.HashMap; +import java.util.Map; +import java.util.Optional; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.vault.VaultException; +import org.springframework.vault.support.VaultResponse; +import org.springframework.vault.support.VaultToken; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestOperations; + +/** + * JWT implementation of {@link ClientAuthentication}. {@link JwtAuthentication} uses a + * JSON Web Token to login into Vault. JWT and Role are sent in the login request to Vault + * to obtain a {@link VaultToken}. + * + * @author Nanne Baars + * @since 3.0.4 + * @see JwtAuthenticationOptions + * @see RestOperations + * @see Vault Auth Backend: + * JWT + */ +public class JwtAuthentication implements ClientAuthentication, AuthenticationStepsFactory { + + public static final String DEFAULT_JWT_AUTHENTICATION_PATH = "jwt"; + + private static final Log logger = LogFactory.getLog(JwtAuthentication.class); + + private final JwtAuthenticationOptions options; + + private final RestOperations restOperations; + + /** + * Create a {@link JwtAuthentication} using {@link JwtAuthenticationOptions} and + * {@link RestOperations}. + * @param options must not be {@literal null}. + * @param restOperations must not be {@literal null}. + */ + public JwtAuthentication(JwtAuthenticationOptions options, RestOperations restOperations) { + Assert.notNull(options, "JwtAuthenticationOptions must not be null"); + Assert.notNull(restOperations, "RestOperations must not be null"); + + this.options = options; + this.restOperations = restOperations; + } + + private static Map getJwtLogin(String role, String jwt) { + Map login = new HashMap<>(); + + login.put("jwt", jwt); + if (StringUtils.hasText(role)) { + login.put("role", role); + } + + return login; + } + + @Override + public AuthenticationSteps getAuthenticationSteps() { + return AuthenticationSteps.fromSupplier(options.getJwtSupplier()) + .map(token -> getJwtLogin(options.getRole(), token)) + .login(getLoginPath()); + } + + @Override + public VaultToken login() throws VaultException { + Map login = getJwtLogin(this.options.getRole(), this.options.getJwtSupplier().get()); + + try { + VaultResponse response = this.restOperations.postForObject(getLoginPath(), login, VaultResponse.class); + + Assert.state(response != null && response.getAuth() != null, "Auth field must not be null"); + + logger.debug("Login successful using JWT authentication"); + + return LoginTokenUtil.from(response.getAuth()); + } + catch (RestClientException e) { + throw VaultLoginException.create("JWT", e); + } + } + + private String getLoginPath() { + return AuthenticationUtil + .getLoginPath(Optional.ofNullable(options.getPath()).orElse(DEFAULT_JWT_AUTHENTICATION_PATH)); + } + +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/authentication/JwtAuthenticationOptions.java b/spring-vault-core/src/main/java/org/springframework/vault/authentication/JwtAuthenticationOptions.java new file mode 100644 index 00000000..439c7c3c --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/authentication/JwtAuthenticationOptions.java @@ -0,0 +1,157 @@ +/* + * Copyright 2017-2023 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 + * + * https://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.function.Supplier; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Authentication options for {@link JwtAuthentication}. + *

+ * Authentication options provide the role and the JWT. {@link JwtAuthenticationOptions} + * can be constructed using {@link #builder()}. Instances of this class are immutable once + * constructed. + *

+ * + * @author Nanne Baars + * @since 3.0.4 + * @see JwtAuthentication + * @see #builder() + */ +public class JwtAuthenticationOptions { + + /** + * Path of the JWT authentication backend mount. Optional and defaults to + * {@literal jwt}. + */ + @Nullable + private final String path; + + /** + * Name of the role against which the login is being attempted. Defaults to configured + * default_role if not provided. See + * Vault + * JWT configuration + */ + @Nullable + private final String role; + + /** + * Supplier instance to obtain a service account JSON Web Tokens. + */ + private final Supplier jwtSupplier; + + private JwtAuthenticationOptions(String role, Supplier jwtSupplier, String path) { + + this.role = role; + this.jwtSupplier = jwtSupplier; + this.path = path; + } + + /** + * @return a new {@link JwtAuthenticationOptionsBuilder}. + */ + public static JwtAuthenticationOptionsBuilder builder() { + return new JwtAuthenticationOptionsBuilder(); + } + + /** + * @return name of the role against which the login is being attempted. + */ + public String getRole() { + return this.role; + } + + /** + * @return JSON Web Token. + */ + public Supplier getJwtSupplier() { + return this.jwtSupplier; + } + + /** + * @return the path of the kubernetes authentication backend mount. + */ + public String getPath() { + return this.path; + } + + /** + * Builder for {@link JwtAuthenticationOptions}. + */ + public static class JwtAuthenticationOptionsBuilder { + + private String role; + + private Supplier jwtSupplier; + + private String path; + + /** + * Configure the role. + * @param role name of the role against which the login is being attempted, must + * not be {@literal null} or empty. + * @return {@code this} {@link JwtAuthenticationOptionsBuilder}. + */ + public JwtAuthenticationOptionsBuilder role(String role) { + + Assert.hasText(role, "Role must not be empty"); + + this.role = role; + return this; + } + + /** + * Configure the mount path. + * @param path must not be {@literal null} or empty. + * @return {@code this} {@link JwtAuthenticationOptionsBuilder}. + */ + public JwtAuthenticationOptionsBuilder path(String path) { + + Assert.hasText(path, "Path must not be empty"); + + this.path = path; + return this; + } + + /** + * Configure the {@link Supplier} to obtain a JWT authentication token. + * @param jwtSupplier must not be {@literal null}. + * @return {@code this} {@link JwtAuthenticationOptionsBuilder}. + */ + public JwtAuthenticationOptionsBuilder jwt(Supplier jwtSupplier) { + + Assert.notNull(jwtSupplier, "Jwt supplier must not be null"); + + this.jwtSupplier = jwtSupplier; + return this; + } + + /** + * Build a new {@link JwtAuthenticationOptions} instance. + * @return a new {@link JwtAuthenticationOptions}. + */ + public JwtAuthenticationOptions build() { + + Assert.notNull(this.jwtSupplier, "JWT must not be null"); + + return new JwtAuthenticationOptions(this.role, this.jwtSupplier, this.path); + } + + } + +} diff --git a/spring-vault-core/src/test/java/org/springframework/vault/authentication/JwtAuthenticationIntegrationTest.java b/spring-vault-core/src/test/java/org/springframework/vault/authentication/JwtAuthenticationIntegrationTest.java new file mode 100644 index 00000000..bf2a07a9 --- /dev/null +++ b/spring-vault-core/src/test/java/org/springframework/vault/authentication/JwtAuthenticationIntegrationTest.java @@ -0,0 +1,127 @@ +/* + * Copyright 2017-2023 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 + * + * https://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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.vault.authentication.JwtAuthentication.DEFAULT_JWT_AUTHENTICATION_PATH; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSObject; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jwt.JWTClaimsSet; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.Date; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.vault.util.IntegrationTestSupport; +import org.springframework.vault.util.Settings; +import org.springframework.vault.util.TestRestTemplateFactory; + +public class JwtAuthenticationIntegrationTest extends IntegrationTestSupport { + + private KeyPair keyPair; + + private KeyPair generateRsaKey() throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048); + return keyPairGenerator.generateKeyPair(); + } + + private String encodePublicKey() { + return String.format(""" + -----BEGIN PUBLIC KEY----- + %s + -----END PUBLIC KEY----- + """, Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded())); + } + + @BeforeEach + void before() throws Exception { + keyPair = generateRsaKey(); + if (!prepare().hasAuth("jwt")) { + prepare().mountAuth("jwt"); + } + + prepare().getVaultOperations().doWithSession(restOperations -> { + var jwtConfig = Map.of( // + "jwt_validation_pubkeys", encodePublicKey(), // + "oidc_client_id", "", // + "oidc_client_secret", ""); + restOperations.postForEntity("auth/jwt/config", jwtConfig, Map.class); + + var roleData = Map.of("role_type", DEFAULT_JWT_AUTHENTICATION_PATH, // + "bound_audiences", "", // + "bound_subject", "admin", // + "user_claim", "user", // + "group_claims", "group"); + return restOperations.postForEntity("auth/jwt/role/my-role", roleData, Map.class); + }); + } + + @Test + void shouldLoginSuccessfully() throws Exception { + var jwt = createToken("Administrator"); + var restTemplate = TestRestTemplateFactory.create(Settings.createSslConfiguration()); + + var loginToken = new JwtAuthentication( + JwtAuthenticationOptions.builder().jwt(() -> jwt).role("my-role").build(), restTemplate) + .login(); + + assertThat(loginToken.getToken()).startsWith("hvs."); + } + + @Test + void claimChangedInTokenShouldFailSignatureVerification() throws Exception { + var token1 = createToken("Administrator"); + var token2 = createToken("Administrator2"); + // Different user claim with signature of token1 makes an invalid token + var jwt = token2.substring(0, token2.lastIndexOf('.') + 1) + token1.substring(token1.lastIndexOf('.') + 1); + + var restTemplate = TestRestTemplateFactory.create(Settings.createSslConfiguration()); + + assertThatThrownBy( + () -> new JwtAuthentication(JwtAuthenticationOptions.builder().jwt(() -> jwt).role("my-role").build(), + restTemplate) + .login()) + .isInstanceOf(VaultLoginException.class) + .hasMessage( + "Cannot login using JWT: error validating token: error verifying token signature: no known key successfully validated the token signature"); + } + + private String createToken(String user) throws JOSEException { + var signer = new RSASSASigner(keyPair.getPrivate()); + var header = new JWSHeader(JWSAlgorithm.RS256); + var body = new JWSObject(header, + new JWTClaimsSet.Builder().audience("local") + .subject("admin") + .claim("user", user) + .issueTime(new Date()) + .expirationTime(java.sql.Timestamp.valueOf(LocalDateTime.now().plusDays(1))) + .issuer("http://localhost:8000") + .build() + .toPayload()); + body.sign(signer); + return body.serialize(); + } + +} diff --git a/src/main/asciidoc/reference/authentication.adoc b/src/main/asciidoc/reference/authentication.adoc index 1b9e02d7..9e4ce595 100644 --- a/src/main/asciidoc/reference/authentication.adoc +++ b/src/main/asciidoc/reference/authentication.adoc @@ -828,6 +828,42 @@ See also: * https://www.vaultproject.io/api-docs/auth/radius[Vault Documentation: Using the RADIUS auth backend] * https://www.vaultproject.io/api-docs/auth/okta[Vault Documentation: Using the Okta auth backend] +[[vault.authentication.jwt]] +== JWT authentication + + +Configuring JWT authentication requires at least the signed JWT to be provided: + +==== +[source,java] +---- +@Configuration +class AppConfig extends AbstractVaultConfiguration { + + // … + + @Override + public ClientAuthentication clientAuthentication() { + + JwtAuthenticationOptions options = JwtAuthenticationOptions.builder() + .role(…).jwt(…).path(…).build(); + + return new JwtAuthentication(options, restOperations()); + } + + // … +} +---- +==== +You can configure the authentication via `JwtAuthenticationOptions`. + +On the Vault side you can configure the JWT backend by enabling the JWT auth backend and creating a role. You can either use `oidc_discovery_url`, `jwks_url` or `jwt_validation_pubkeys` to configure the JWT backend. + +See also: + +* https://developer.hashicorp.com/vault/docs/auth/jwt[Vault Documentation: Using the JWT auth backend] + + [[vault.authentication.steps]] == Authentication Steps