From 48caeefdf6d307c8216075004ea6726609e9d4b6 Mon Sep 17 00:00:00 2001 From: Toshiaki Maki Date: Thu, 14 May 2026 16:19:15 +0900 Subject: [PATCH] Add JWT authentication support Signed-off-by: Toshiaki Maki --- docs/modules/ROOT/pages/authentication.adoc | 27 ++++++ .../config/ClientAuthenticationFactory.java | 31 +++++++ .../cloud/vault/config/VaultProperties.java | 88 ++++++++++++++++++- .../ClientAuthenticationFactoryUnitTests.java | 83 +++++++++++++++++ 4 files changed, 228 insertions(+), 1 deletion(-) diff --git a/docs/modules/ROOT/pages/authentication.adoc b/docs/modules/ROOT/pages/authentication.adoc index 424ac177..3d93cf57 100644 --- a/docs/modules/ROOT/pages/authentication.adoc +++ b/docs/modules/ROOT/pages/authentication.adoc @@ -570,6 +570,33 @@ See also: * https://developer.hashicorp.com/vault/api-docs/auth/github[Vault Documentation: GitGub] * https://cli.github.com/[GitHub CLI] +[[vault.config.authentication.jwt]] +== JWT authentication + +JWT authentication allows to authenticate with Vault using a JSON Web Token issued by an OIDC-compatible platform (e.g. GitHub Actions, EKS IRSA, GitLab CI/CD). +The JWT can be provided inline or read from a file. + +.application.yml with all JWT authentication properties +[source,yaml] +---- +spring.cloud.vault: + authentication: JWT + jwt: + role: my-role + jwt-path: jwt + token: ${VAULT_JWT_TOKEN:} + token-file: ${AWS_WEB_IDENTITY_TOKEN_FILE:} +---- + +* `role` sets the Role. Optional; if empty, the JWT auth method's `default_role` is used. +* `jwt-path` sets the path of the JWT auth method mount. +* `token` sets a static JWT to use. +* `token-file` points to a file containing the JWT. The file is re-read on each login to support token rotation. + +NOTE: `token` and `token-file` are mutually exclusive — exactly one must be set. + +See also: https://developer.hashicorp.com/vault/docs/auth/jwt[Vault Documentation: JWT/OIDC] + [[vault.authentication.gcpiam]] [[vault.config.authentication.kubernetes]] == Kubernetes authentication diff --git a/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/ClientAuthenticationFactory.java b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/ClientAuthenticationFactory.java index 607f267a..6108fa53 100644 --- a/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/ClientAuthenticationFactory.java +++ b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/ClientAuthenticationFactory.java @@ -21,6 +21,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; import software.amazon.awssdk.auth.credentials.AwsCredentials; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; @@ -58,6 +59,8 @@ import org.springframework.vault.authentication.GcpComputeAuthenticationOptions.GcpComputeAuthenticationOptionsBuilder; import org.springframework.vault.authentication.GitHubAuthentication; import org.springframework.vault.authentication.GitHubAuthenticationOptions; +import org.springframework.vault.authentication.JwtAuthentication; +import org.springframework.vault.authentication.JwtAuthenticationOptions; import org.springframework.vault.authentication.KubernetesAuthentication; import org.springframework.vault.authentication.KubernetesAuthenticationOptions; import org.springframework.vault.authentication.KubernetesServiceAccountTokenFile; @@ -78,6 +81,7 @@ * @author Michal Budzyn * @author Quincy Conduff * @author Issam El-atif + * @author Toshiaki Maki * @since 1.1 */ class ClientAuthenticationFactory { @@ -113,6 +117,7 @@ ClientAuthentication createClientAuthentication() { case GCP_GCE -> gcpGceAuthentication(this.vaultProperties); case GCP_IAM -> gcpIamAuthentication(this.vaultProperties); case GITHUB -> gitHubAuthentication(this.vaultProperties); + case JWT -> jwtAuthentication(this.vaultProperties); case KUBERNETES -> kubernetesAuthentication(this.vaultProperties); case PCF -> pcfAuthentication(this.vaultProperties); case TOKEN -> tokenAuthentication(this.vaultProperties); @@ -337,6 +342,32 @@ private static String invokeGitHubCli(String command, String subcommand) { } } + private ClientAuthentication jwtAuthentication(VaultProperties vaultProperties) { + + VaultProperties.JwtProperties jwt = vaultProperties.getJwt(); + + Assert.hasText(jwt.getJwtPath(), "Mount path (spring.cloud.vault.jwt.jwt-path) must not be empty"); + + boolean hasToken = StringUtils.hasText(jwt.getToken()); + boolean hasTokenFile = StringUtils.hasText(jwt.getTokenFile()); + Assert.isTrue(hasToken ^ hasTokenFile, + "Exactly one of spring.cloud.vault.jwt.token or spring.cloud.vault.jwt.token-file must be set"); + + // ResourceCredentialSupplier re-reads the file on each get() call (invoked on + // every JwtAuthentication.login()), so re-logins pick up rotated tokens. + Supplier jwtSupplier = hasToken ? jwt::getToken : new ResourceCredentialSupplier(jwt.getTokenFile()); + + JwtAuthenticationOptions.JwtAuthenticationOptionsBuilder builder = JwtAuthenticationOptions.builder() + .path(jwt.getJwtPath()) + .jwtSupplier(jwtSupplier); + + if (StringUtils.hasText(jwt.getRole())) { + builder.role(jwt.getRole()); + } + + return new JwtAuthentication(builder.build(), this.restOperations); + } + private ClientAuthentication kubernetesAuthentication(VaultProperties vaultProperties) { VaultProperties.KubernetesProperties kubernetes = vaultProperties.getKubernetes(); diff --git a/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultProperties.java b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultProperties.java index 8fdff356..0314826a 100644 --- a/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultProperties.java +++ b/spring-cloud-vault-config/src/main/java/org/springframework/cloud/vault/config/VaultProperties.java @@ -43,6 +43,7 @@ * @author Grenville Wilson * @author Mårten Svantesson * @author Issam El-atif + * @author Toshiaki Maki */ @ConfigurationProperties(VaultProperties.PREFIX) public class VaultProperties implements EnvironmentAware { @@ -131,6 +132,8 @@ public class VaultProperties implements EnvironmentAware { private GithubProperties github = new GithubProperties(); + private JwtProperties jwt = new JwtProperties(); + private KubernetesProperties kubernetes = new KubernetesProperties(); private PcfProperties pcf = new PcfProperties(); @@ -321,6 +324,14 @@ public void setGithub(GithubProperties github) { this.github = github; } + public JwtProperties getJwt() { + return this.jwt; + } + + public void setJwt(JwtProperties jwt) { + this.jwt = jwt; + } + public KubernetesProperties getKubernetes() { return this.kubernetes; } @@ -382,7 +393,8 @@ public void setAuthentication(AuthenticationMethod authentication) { */ public enum AuthenticationMethod { - APPROLE, AWS_EC2, AWS_IAM, AZURE_MSI, CERT, CUBBYHOLE, GCP_GCE, GCP_IAM, GITHUB, KUBERNETES, NONE, PCF, TOKEN; + APPROLE, AWS_EC2, AWS_IAM, AZURE_MSI, CERT, CUBBYHOLE, GCP_GCE, GCP_IAM, GITHUB, JWT, KUBERNETES, NONE, PCF, + TOKEN; } @@ -1038,6 +1050,80 @@ public void setServiceAccountTokenFile(String serviceAccountTokenFile) { } + /** + * JWT authentication properties. + * + * @author Toshiaki Maki + * @since 5.0.2 + */ + public static class JwtProperties { + + /** + * Mount path of the JWT authentication backend. + */ + private String jwtPath = "jwt"; + + /** + * Name of the role against which the login is being attempted. Optional; when not + * set, the JWT auth method's {@code default_role} configured on the Vault server + * is used. + */ + @Nullable + private String role; + + /** + * Static JWT used for authentication. Mutually exclusive with {@link #tokenFile}. + * Typically resolved from an environment variable via a property placeholder. + */ + @Nullable + private String token; + + /** + * Path to a file containing the JWT used for authentication. Mutually exclusive + * with {@link #token}. Useful for platforms that mount the JWT as a file (e.g. + * EKS IRSA via {@code AWS_WEB_IDENTITY_TOKEN_FILE}, Kubernetes projected service + * account tokens). The file is re-read on each login to support token rotation. + */ + @Nullable + private String tokenFile; + + public String getJwtPath() { + return this.jwtPath; + } + + public void setJwtPath(String jwtPath) { + this.jwtPath = jwtPath; + } + + @Nullable + public String getRole() { + return this.role; + } + + public void setRole(@Nullable String role) { + this.role = role; + } + + @Nullable + public String getToken() { + return this.token; + } + + public void setToken(@Nullable String token) { + this.token = token; + } + + @Nullable + public String getTokenFile() { + return this.tokenFile; + } + + public void setTokenFile(@Nullable String tokenFile) { + this.tokenFile = tokenFile; + } + + } + /** * PCF properties. */ diff --git a/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/ClientAuthenticationFactoryUnitTests.java b/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/ClientAuthenticationFactoryUnitTests.java index fc89d6de..b1c26232 100644 --- a/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/ClientAuthenticationFactoryUnitTests.java +++ b/spring-cloud-vault-config/src/test/java/org/springframework/cloud/vault/config/ClientAuthenticationFactoryUnitTests.java @@ -24,6 +24,7 @@ import java.nio.file.StandardOpenOption; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import software.amazon.awssdk.core.SdkSystemSetting; import software.amazon.awssdk.regions.Region; @@ -38,12 +39,15 @@ import org.springframework.vault.authentication.ClientAuthentication; import org.springframework.vault.authentication.ClientCertificateAuthentication; import org.springframework.vault.authentication.GitHubAuthentication; +import org.springframework.vault.authentication.JwtAuthentication; +import org.springframework.vault.authentication.JwtAuthenticationOptions; import org.springframework.vault.authentication.PcfAuthentication; import org.springframework.vault.authentication.TokenAuthentication; import org.springframework.vault.support.VaultToken; import org.springframework.web.client.RestTemplate; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -53,6 +57,7 @@ * @author Mark Paluch * @author Quincy Conduff * @author Issam El-atif + * @author Toshiaki Maki */ public class ClientAuthenticationFactoryUnitTests { @@ -274,4 +279,82 @@ public void tokenAuthShouldFailIfTokenFileNotExistsAndTokenEmpty() throws IOExce assertThatIllegalStateException().isThrownBy(factory::createClientAuthentication); } + @Test + public void shouldSupportJwtWithStaticToken() { + + VaultProperties properties = new VaultProperties(); + properties.setAuthentication(VaultProperties.AuthenticationMethod.JWT); + properties.getJwt().setRole("my-role"); + properties.getJwt().setToken("eyJ.static-jwt"); + + ClientAuthentication clientAuthentication = new ClientAuthenticationFactory(properties, new RestTemplate(), + new RestTemplate()) + .createClientAuthentication(); + + assertThat(clientAuthentication).isInstanceOf(JwtAuthentication.class); + + JwtAuthenticationOptions options = (JwtAuthenticationOptions) ReflectionTestUtils.getField(clientAuthentication, + "options"); + assertThat(options.getPath()).isEqualTo("jwt"); + assertThat(options.getRole()).isEqualTo("my-role"); + assertThat(options.getJwtSupplier().get()).isEqualTo("eyJ.static-jwt"); + } + + @Test + public void shouldSupportJwtWithTokenFile(@TempDir Path tmp) throws IOException { + + Path tokenFile = tmp.resolve("oidc-token"); + Files.writeString(tokenFile, "eyJ.file-jwt"); + + VaultProperties properties = new VaultProperties(); + properties.setAuthentication(VaultProperties.AuthenticationMethod.JWT); + properties.getJwt().setJwtPath("custom-jwt"); + properties.getJwt().setTokenFile(tokenFile.toString()); + + ClientAuthentication clientAuthentication = new ClientAuthenticationFactory(properties, new RestTemplate(), + new RestTemplate()) + .createClientAuthentication(); + + assertThat(clientAuthentication).isInstanceOf(JwtAuthentication.class); + + JwtAuthenticationOptions options = (JwtAuthenticationOptions) ReflectionTestUtils.getField(clientAuthentication, + "options"); + assertThat(options.getPath()).isEqualTo("custom-jwt"); + assertThat(options.getRole()).isNull(); + assertThat(options.getJwtSupplier().get()).isEqualTo("eyJ.file-jwt"); + } + + @Test + public void shouldFailJwtWhenBothTokenAndTokenFileSet(@TempDir Path tmp) throws IOException { + + Path tokenFile = tmp.resolve("oidc-token"); + Files.writeString(tokenFile, "eyJ.file-jwt"); + + VaultProperties properties = new VaultProperties(); + properties.setAuthentication(VaultProperties.AuthenticationMethod.JWT); + properties.getJwt().setToken("eyJ.static-jwt"); + properties.getJwt().setTokenFile(tokenFile.toString()); + + ClientAuthenticationFactory factory = new ClientAuthenticationFactory(properties, new RestTemplate(), + new RestTemplate()); + + assertThatIllegalArgumentException().isThrownBy(factory::createClientAuthentication) + .withMessage( + "Exactly one of spring.cloud.vault.jwt.token or spring.cloud.vault.jwt.token-file must be set"); + } + + @Test + public void shouldFailJwtWhenNoTokenSourceSet() { + + VaultProperties properties = new VaultProperties(); + properties.setAuthentication(VaultProperties.AuthenticationMethod.JWT); + + ClientAuthenticationFactory factory = new ClientAuthenticationFactory(properties, new RestTemplate(), + new RestTemplate()); + + assertThatIllegalArgumentException().isThrownBy(factory::createClientAuthentication) + .withMessage( + "Exactly one of spring.cloud.vault.jwt.token or spring.cloud.vault.jwt.token-file must be set"); + } + }