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