I know it's a bit late, but it was exactly what we needed in our company. No issuer url's for auth-servers.
Further more, no need for auth-servers, too, as clients requesting the protected resource on the resource server, just have to generate signed JWT with the private key and send it in the http header as Authorization Bearer token. On the resource server only clients having their public key (certificates) imported in the truststore will be allowed to access the resources.
So thanks to the tips given by @Norbert Dopjera, I implemented a custom AuthenticationManagerResolver that will look in the JWT header for kid (key id) transporting the alias for a certificate (public key) stored in a truststore.jks file, will retrieve this public key and create a JWTDecoder that will check if the incoming JWT as Authorization Bearer from the http header, was signed with the corresponding private key.
Here is the whole code using Spring Boot 2.7.1:
import java.io.FileInputStream;
import java.io.IOException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.interfaces.RSAPublicKey;
import java.text.ParseException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import javax.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationManagerResolver;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider;
import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver;
import org.springframework.security.oauth2.server.resource.web.DefaultBearerTokenResolver;
import org.springframework.util.StringUtils;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jwt.JWTParser;
public class TenantAuthenticationManagerResolver implements AuthenticationManagerResolver<HttpServletRequest> {
private static final Logger log = LoggerFactory.getLogger(TenantAuthenticationManagerResolver.class);
private final Map<String, AuthenticationManager> authenticationManagers = new ConcurrentHashMap<>();
private final BearerTokenResolver resolver = new DefaultBearerTokenResolver();
private String trustetoreFile;
private char[] storePasswd;
public TenantAuthenticationManagerResolver(String truststoreFile, char[] storePasswd) {
super();
this.trustetoreFile = truststoreFile;
this.storePasswd = storePasswd;
}
@Override
public AuthenticationManager resolve(HttpServletRequest request) {
try {
return this.authenticationManagers.computeIfAbsent(toTenant(request), this::fromTenant);
}
catch (Exception e) {
throw new InvalidBearerTokenException(e.getMessage());
}
}
private String toTenant(HttpServletRequest request) throws ParseException {
String jwt = this.resolver.resolve(request);
String keyId = ((JWSHeader) JWTParser.parse(jwt).getHeader()).getKeyID();
if (!StringUtils.hasText(keyId)) {
throw new IllegalArgumentException("KeyID missing");
}
return keyId;
}
private AuthenticationManager fromTenant(String tenant) {
return new JwtAuthenticationProvider(jwtDecoder(tenant))::authenticate;
}
private JwtDecoder jwtDecoder(String kid) {
log.info("Building JwtDecoder for {}", kid);
try {
NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey(getPublicKeyFromTruststore(kid)).signatureAlgorithm(SignatureAlgorithm.from("RS512")).build();
OAuth2TokenValidator<Jwt> withDefault = JwtValidators.createDefault();
OAuth2TokenValidator<Jwt> withDelegating = new DelegatingOAuth2TokenValidator<>(withDefault);
jwtDecoder.setJwtValidator(withDelegating);
return jwtDecoder;
}
catch (Exception e) {
throw new IllegalStateException(e.getMessage());
}
}
private RSAPublicKey getPublicKeyFromTruststore(String certificateAlias) throws IOException, KeyStoreException, NoSuchAlgorithmException, CertificateException {
try (FileInputStream myKeys = new FileInputStream(trustetoreFile)) {
log.info("Opening truststore");
KeyStore myTrustStore = KeyStore.getInstance(KeyStore.getDefaultType());
myTrustStore.load(myKeys, storePasswd);
Certificate certificate = myTrustStore.getCertificate(certificateAlias);
if (certificate == null) {
throw new IllegalArgumentException("No entry found for alias " + certificateAlias);
}
return (RSAPublicKey) certificate.getPublicKey();
}
}
}
And now the security configuration:
import javax.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.security.authentication.AuthenticationManagerResolver;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
import your_package.TenantAuthenticationManagerResolver;
@EnableWebSecurity
public class SecurityConfig {
@Value("${jwt.keystore.location}")
private String keyStore;
@Value("${jwt.keystore.password}")
private char[] storePasswd;
@Value("${jwt.algorithm}")
private String algorithm;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, AuthenticationManagerResolver<HttpServletRequest> tenantAuthManagerResolver) throws Exception {
//@formatter:off
http
.authorizeRequests()
.mvcMatchers("/").permitAll()
.mvcMatchers("/protectedservice/**").authenticated()
.and().cors()
.and().oauth2ResourceServer(oauth2 -> oauth2
.authenticationManagerResolver(tenantAuthManagerResolver)
);
//@formatter:on
return http.build();
}
@Bean
public AuthenticationManagerResolver<HttpServletRequest> tenantAuthManagerResolver() {
return new TenantAuthenticationManagerResolver(keyStore, storePasswd);
}
}
Dependencies:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
Properties in the application.properties:
jwt.keystore.location=/absolute_path_to/truststore.jks
jwt.keystore.password=your_trustsrore_passwd
jwt.algorithm=RS512
Further requirements:
Creation of a keypair (a self signed certificate) in a keystore with Java keytool (stays on the authentication/authorization server):
keytool -genkey -keyalg RSA -alias my_alias -keystore my_keystore_file.jks -storepass my_keystore_pass -validity 360 -keysize 2048 -storetype JKS
Extract the public key (the certificate) from the keystore:
keytool -exportcert -alias my_alias -keystore my_keystore.jks -storepass my_keystore_pass -rfc -file my_cert_file.pem
Import this certificate in a new keystore (truststore) holding the public keys (stays on the resource server):
keytool -importcert -alias my_alias -file my_cert_file.pem -keystore my_truststore_file.jks -storepass my_store_pass
For multi-tenancy, add more keypairs with different aliases to the keystore then extract the certificate (public key) and add it to the truststore. The my_truststore_file.jks will be used in the configured property jwt.keystore.location of the resource server.
Code for generating signed JWT with the private key stored in the keystore (this should be implemented on the Security Oauth2 Auth Servers). I put this code in a JUnit test class:
import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.security.Key;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.UnrecoverableKeyException;
import java.security.cert.CertificateException;
import java.time.Instant;
import java.time.temporal.ChronoField;
import java.util.Base64;
import java.util.Date;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.JWSSigner;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
class TestJWTGeneration {
@Test
void testCreateJWTNimusKS() throws Exception {
PrivateKey privateKey = getPrivateKeyFromKeystore("/absolute_path_to/keystore.jks", "my_alias");
// Create RSA-signer with the private key
JWSSigner signer = new RSASSASigner(privateKey);
//@formatter:off
// Prepare JWT with claims set
// 1 day JWT validity
Date expirationDate = Date.from(Instant.now().plus(1L, ChronoField.DAY_OF_MONTH.getBaseUnit()));
JWTClaimsSet claimsSet = new JWTClaimsSet.Builder()
.subject("my_subject")
.issuer("https://my_oauth2_server.com/")
.audience("my_audience")
.issueTime(new Date())
.claim("nonce", Base64.getEncoder().encodeToString(UUID.randomUUID().toString().getBytes()))
.expirationTime(expirationDate)
.build();
//@formatter:on
// put the certificate alias in the JWT header as "kid" field (Key ID)
final JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.RS512).type(JOSEObjectType.JWT).keyID("my_alias").build();
final SignedJWT signedJWT = new SignedJWT(header, claimsSet);
signedJWT.sign(signer);
String jwtSigned = signedJWT.serialize();
assertNotNull(jwtSigned);
System.out.println("##Nimbus JWT=" + jwtSigned);
}
public static PrivateKey getPrivateKeyFromKeystore(String pubKeyFile, String keyAlias) throws FileNotFoundException, IOException, KeyStoreException, NoSuchAlgorithmException, CertificateException, UnrecoverableKeyException {
try (FileInputStream myKeys = new FileInputStream(pubKeyFile)) {
KeyStore myTrustStore = KeyStore.getInstance(KeyStore.getDefaultType());
myTrustStore.load(myKeys, "my_keystore_pass".toCharArray());
Key key = myTrustStore.getKey(keyAlias, "my_keystore_pass".toCharArray());
return (PrivateKey) key;
}
}
}