Support JWT Authentication

Closes gh-689
Original pull request: gh-802
This commit is contained in:
Nanne Baars
2023-06-28 11:15:51 +02:00
committed by Mark Paluch
parent d877da4328
commit 1367275b66
5 changed files with 435 additions and 0 deletions

View File

@@ -314,6 +314,13 @@
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.30.2</version>
<scope>test</scope>
</dependency>
<!-- Logging -->
<dependency>

View File

@@ -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 <a href="https://www.vaultproject.io/api-docs/auth/jwt">Vault Auth Backend:
* JWT</a>
*/
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<String, String> getJwtLogin(String role, String jwt) {
Map<String, String> 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<String, String> 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));
}
}

View File

@@ -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}.
* <p>
* Authentication options provide the role and the JWT. {@link JwtAuthenticationOptions}
* can be constructed using {@link #builder()}. Instances of this class are immutable once
* constructed.
* <p>
*
* @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
* <a href="https://developer.hashicorp.com/vault/api-docs/auth/jwt#configure">Vault
* JWT configuration</a>
*/
@Nullable
private final String role;
/**
* Supplier instance to obtain a service account JSON Web Tokens.
*/
private final Supplier<String> jwtSupplier;
private JwtAuthenticationOptions(String role, Supplier<String> 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<String> 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<String> 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<String> 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);
}
}
}

View File

@@ -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();
}
}

View File

@@ -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