Reputation: 71
We have the following scenario:
Problem is the resource server is only configured with 1 key (currently)- so it can only accept tokens from 1 auth-server.
Is there any conceivable way to support multiple keys in our resource server to decode JWTs coming from different auth-servers?
We basically want to do this but with multiple keys: https://docs.spring.io/spring-security/site/docs/current/reference/html5/#oauth2resourceserver-jwt-decoder-public-key
Spring Security 5.3 indicates this may be possible with 'multi-tenancy' https://docs.spring.io/spring-security/site/docs/current/reference/html5/#webflux-oauth2resourceserver-multitenancy
It's a basic configuration
@Value("${security.oauth2.resourceserver.jwt.key-value}")
RSAPublicKey key;
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// using new Spring Security SpE"{{LOCATOR_BASE_URL}}"L
//https://docs.spring.io/spring-security/site/docs/current/reference/htmlsingle/#webflux-oauth2resourceserver-jwt-authorization
.authorizeRequests(authorizeRequests ->
authorizeRequests
.antMatchers("/shipments/**").hasAuthority("SCOPE_DOMPick")
.anyRequest().authenticated()
)
.csrf().disable()
// ****** this is the new DSL way in Spring Security 5.2 instead of Spring Security Oauth @EnableResourceServer ******
.oauth2ResourceServer(oauth2ResourceServer ->
oauth2ResourceServer
.jwt(jwt ->
jwt.decoder(jwtDecoder())
)
);
}
// static key
@Bean
JwtDecoder jwtDecoder() {
return NimbusJwtDecoder.withPublicKey(this.key).build();
Upvotes: 2
Views: 6109
Reputation: 1
The example below have one public key in the PEM format and and also gets the Authorization from Google.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(c -> c.disable())
.cors(c -> c.configurationSource(corsConfigurationSource()))
.authorizeHttpRequests(a -> {
a.requestMatchers("/", "/error", "/login").permitAll();
a.anyRequest().authenticated();
})
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(j -> j.jwt(Customizer.withDefaults()))
.logout(l -> l
.logoutSuccessUrl("/").permitAll())
;
return http.build();
}
@Bean
public JwtDecoder jwtDecoder() {
return new JwtDecoder() {
@Override
public Jwt decode(String token) throws JwtException {
try {
if(JWTParser.parse(token).getJWTClaimsSet().getIssuer().equals("self")){
System.err.println("Self!");
JwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey(rsaKeys.publicKey()).build();
return jwtDecoder.decode(token);
} else
if(JWTParser.parse(token).getJWTClaimsSet().getIssuer().equals("https://accounts.google.com")){
System.err.println("Google");
JwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation("https://accounts.google.com").build();
return jwtDecoder.decode(token);
}
} catch (ParseException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
return null;
}
};
Upvotes: -1
Reputation: 716
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;
}
}
}
Upvotes: -1
Reputation: 751
Yes Spring Security 5.3 allow's you to use multiple jwk-uri key's. Please read my answer here:
https://stackoverflow.com/a/61615389/12053054
If you cannot use this version of SS it is possible to manually configure spring security to use multiple jwk-uri key's. (Follow link i have provided to see how).
This part of Spring Security doc's specify how to do it with Spring Security 5.3: https://docs.spring.io/spring-security/site/docs/current/reference/html5/#oauth2resourceserver-multitenancy
JwtIssuerAuthenticationManagerResolver authenticationManagerResolver = new JwtIssuerAuthenticationManagerResolver
("https://idp.example.org/issuerOne", "https://idp.example.org/issuerTwo");
http
.authorizeRequests(authorize -> authorize
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.authenticationManagerResolver(authenticationManagerResolver)
);
Note that issuer url's are resolved from incoming token (JWT oauth2 token always contains issuer url where uri for jwk to verify JWT token can be found). By manual configuration (answer i have posted) you can add custom behavior for example: instead of finding which ulr should be used to verify token directly from JWT you can check header's for information that resolves which issuer URL (you have specified them in your spring app) should be used with this request to verify JWT token.
Upvotes: 2