From 548bc7e53bbb9cc33e3fc6dbc139f9939c0b2c7b Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Thu, 29 Jun 2023 16:57:39 +0200 Subject: [PATCH] Polishing. Reformat code. Simplify flow. See gh-689 Original pull request: gh-802 --- .../authentication/JwtAuthentication.java | 38 ++--- .../JwtAuthenticationOptions.java | 50 ++++-- .../JwtAuthenticationIntegrationTest.java | 147 ++++++++++-------- .../asciidoc/reference/authentication.adoc | 71 +++++---- 4 files changed, 170 insertions(+), 136 deletions(-) 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 index 545c42c4..043ecd79 100644 --- 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 @@ -17,9 +17,11 @@ 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.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.StringUtils; import org.springframework.vault.VaultException; @@ -34,7 +36,7 @@ import org.springframework.web.client.RestOperations; * to obtain a {@link VaultToken}. * * @author Nanne Baars - * @since 3.0.4 + * @since 3.1 * @see JwtAuthenticationOptions * @see RestOperations * @see Vault Auth Backend: @@ -57,6 +59,7 @@ public class JwtAuthentication implements ClientAuthentication, AuthenticationSt * @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"); @@ -64,31 +67,22 @@ public class JwtAuthentication implements ClientAuthentication, AuthenticationSt 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()); + .login(AuthenticationUtil.getLoginPath(this.options.getPath())); } @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); + VaultResponse response = this.restOperations + .postForObject(AuthenticationUtil.getLoginPath(this.options.getPath()), login, VaultResponse.class); Assert.state(response != null && response.getAuth() != null, "Auth field must not be null"); logger.debug("Login successful using JWT authentication"); @@ -100,9 +94,17 @@ public class JwtAuthentication implements ClientAuthentication, AuthenticationSt } } - private String getLoginPath() { - return AuthenticationUtil - .getLoginPath(Optional.ofNullable(options.getPath()).orElse(DEFAULT_JWT_AUTHENTICATION_PATH)); + private static Map getJwtLogin(@Nullable String role, String jwt) { + + Map login = new HashMap<>(2); + + login.put("jwt", jwt); + + if (StringUtils.hasText(role)) { + login.put("role", role); + } + + return login; } } 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 index 439c7c3c..a43917f1 100644 --- 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 @@ -16,6 +16,7 @@ package org.springframework.vault.authentication; import java.util.function.Supplier; + import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -25,20 +26,21 @@ import org.springframework.util.Assert; * 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 + * @author Mark Paluch + * @since 3.1 * @see JwtAuthentication * @see #builder() */ public class JwtAuthenticationOptions { + public static final String DEFAULT_JWT_AUTHENTICATION_PATH = "jwt"; + /** * Path of the JWT authentication backend mount. Optional and defaults to * {@literal jwt}. */ - @Nullable private final String path; /** @@ -55,7 +57,7 @@ public class JwtAuthenticationOptions { */ private final Supplier jwtSupplier; - private JwtAuthenticationOptions(String role, Supplier jwtSupplier, String path) { + private JwtAuthenticationOptions(@Nullable String role, Supplier jwtSupplier, String path) { this.role = role; this.jwtSupplier = jwtSupplier; @@ -70,8 +72,10 @@ public class JwtAuthenticationOptions { } /** - * @return name of the role against which the login is being attempted. + * @return name of the role against which the login is being attempted. Can be + * {@literal null} if not configured. */ + @Nullable public String getRole() { return this.role; } @@ -84,7 +88,7 @@ public class JwtAuthenticationOptions { } /** - * @return the path of the kubernetes authentication backend mount. + * @return the path of the JWT authentication backend mount. */ public String getPath() { return this.path; @@ -95,11 +99,26 @@ public class JwtAuthenticationOptions { */ public static class JwtAuthenticationOptionsBuilder { + private String path = DEFAULT_JWT_AUTHENTICATION_PATH; + + @Nullable private String role; + @Nullable private Supplier jwtSupplier; - private String path; + /** + * 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 role. @@ -116,16 +135,17 @@ public class JwtAuthenticationOptions { } /** - * Configure the mount path. - * @param path must not be {@literal null} or empty. + * Configure the JWT authentication token. Vault authentication will use this + * token as singleton. If you want to provide a dynamic token that can change over + * time, see {@link #jwtSupplier(Supplier)}. + * @param jwt must not be {@literal null}. * @return {@code this} {@link JwtAuthenticationOptionsBuilder}. */ - public JwtAuthenticationOptionsBuilder path(String path) { + public JwtAuthenticationOptionsBuilder jwt(String jwt) { - Assert.hasText(path, "Path must not be empty"); + Assert.hasText(jwt, "JWT must not be empty"); - this.path = path; - return this; + return jwtSupplier(() -> jwt); } /** @@ -133,9 +153,9 @@ public class JwtAuthenticationOptions { * @param jwtSupplier must not be {@literal null}. * @return {@code this} {@link JwtAuthenticationOptionsBuilder}. */ - public JwtAuthenticationOptionsBuilder jwt(Supplier jwtSupplier) { + public JwtAuthenticationOptionsBuilder jwtSupplier(Supplier jwtSupplier) { - Assert.notNull(jwtSupplier, "Jwt supplier must not be null"); + Assert.notNull(jwtSupplier, "JWT supplier must not be null"); this.jwtSupplier = jwtSupplier; return this; 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 index bf2a07a9..5a355f2b 100644 --- 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 @@ -15,9 +15,13 @@ */ 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 java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.Date; +import java.util.Map; import com.nimbusds.jose.JOSEException; import com.nimbusds.jose.JWSAlgorithm; @@ -25,23 +29,53 @@ 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.support.VaultToken; import org.springframework.vault.util.IntegrationTestSupport; import org.springframework.vault.util.Settings; import org.springframework.vault.util.TestRestTemplateFactory; +import org.springframework.web.client.RestTemplate; -public class JwtAuthenticationIntegrationTest extends IntegrationTestSupport { +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; + +/** + * Integration tests for {@link KubernetesAuthentication} using + * {@link AuthenticationStepsExecutor}. + * + * @author Nanne Baars + * @author Mark Paluch + */ +class JwtAuthenticationIntegrationTest extends IntegrationTestSupport { private KeyPair keyPair; + @BeforeEach + void before() throws Exception { + + keyPair = generateRsaKey(); + + if (!prepare().hasAuth("jwt")) { + prepare().mountAuth("jwt"); + } + + prepare().getVaultOperations().doWithSession(restOperations -> { + + Map jwtConfig = Map.of("jwt_validation_pubkeys", encodePublicKey(), "oidc_client_id", "", + "oidc_client_secret", ""); + restOperations.postForEntity("auth/jwt/config", jwtConfig, Map.class); + + Map 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); + }); + } + private KeyPair generateRsaKey() throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); return keyPairGenerator.generateKeyPair(); @@ -55,68 +89,16 @@ public class JwtAuthenticationIntegrationTest extends IntegrationTestSupport { """, 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, + + RSASSASigner signer = new RSASSASigner(keyPair.getPrivate()); + JWSHeader header = new JWSHeader(JWSAlgorithm.RS256); + JWSObject 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))) + .expirationTime(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) .issuer("http://localhost:8000") .build() .toPayload()); @@ -124,4 +106,35 @@ public class JwtAuthenticationIntegrationTest extends IntegrationTestSupport { return body.serialize(); } + @Test + void shouldLoginSuccessfully() throws Exception { + + String jwt = createToken("Administrator"); + RestTemplate restTemplate = TestRestTemplateFactory.create(Settings.createSslConfiguration()); + + JwtAuthentication authentication = new JwtAuthentication( + JwtAuthenticationOptions.builder().jwtSupplier(() -> jwt).role("my-role").build(), restTemplate); + VaultToken loginToken = authentication.login(); + + assertThat(loginToken.getToken()).isNotNull(); + } + + @Test + void claimChangedInTokenShouldFailSignatureVerification() throws Exception { + + String token1 = createToken("Administrator"); + String token2 = createToken("Administrator2"); + + // Different user claim with signature of token1 makes an invalid token + String jwt = token2.substring(0, token2.lastIndexOf('.') + 1) + token1.substring(token1.lastIndexOf('.') + 1); + + RestTemplate restTemplate = TestRestTemplateFactory.create(Settings.createSslConfiguration()); + JwtAuthentication authentication = new JwtAuthentication( + JwtAuthenticationOptions.builder().jwtSupplier(() -> jwt).role("my-role").build(), restTemplate); + + assertThatThrownBy(authentication::login).isInstanceOf(VaultLoginException.class) + .hasMessageContaining("Cannot login using JWT", "error validating token", + "error verifying token signature"); + } + } diff --git a/src/main/asciidoc/reference/authentication.adoc b/src/main/asciidoc/reference/authentication.adoc index 9e4ce595..03f096d9 100644 --- a/src/main/asciidoc/reference/authentication.adoc +++ b/src/main/asciidoc/reference/authentication.adoc @@ -743,6 +743,41 @@ See also: * https://www.vaultproject.io/docs/secrets/cubbyhole/index.html[Vault Documentation: Cubbyhole Secret Backend] * https://www.vaultproject.io/docs/concepts/response-wrapping.html[Vault Documentation: Response Wrapping] +[[vault.authentication.jwt]] +== JWT authentication + +Configuring JWT authentication requires the token or a JWT supplier. +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. + +==== +[source,java] +---- +@Configuration +class AppConfig extends AbstractVaultConfiguration { + + // … + + @Override + public ClientAuthentication clientAuthentication() { + + JwtAuthenticationOptions options = JwtAuthenticationOptions.builder() + .role(…).jwt(…).path(…).build(); + + return new JwtAuthentication(options, restOperations()); + } + + // … +} +---- +==== + +See also: + +* https://developer.hashicorp.com/vault/docs/auth/jwt[Vault Documentation: Using the JWT auth backend] + [[vault.authentication.kubernetes]] == Kubernetes authentication @@ -828,42 +863,6 @@ 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