How do I use multiple 'JWK Set Uri' values in the same Spring Boot app?
Asked Answered
U

4

14

I have a requirement to use two different authorization servers (two Okta instances) to validate authentication tokens coming from two different web applications inside a single Spring Boot application which is a back-end REST API layer.

Currently I have one resource server working with the following configuration:

@Configuration
@EnableWebSecurity
public class ResourceServerSecurityConfig extends WebSecurityConfigurerAdapter {

  @Override
  protected void configure(HttpSecurity http) throws Exception{
    http
      .authorizeRequests().antMatchers("/public/**").permitAll()
      .anyRequest().authenticated()
      .and()
      .oauth2ResourceServer().jwt();
  }
}
spring.security.oauth2.resourceserver.jwt.issuer-uri=https://dev-X.okta.com/oauth2/default
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://dev-X.okta.com/oauth2/default/v1/keys

and with dependencies spring-security-oauth2-resource-server and spring-security-oauth2-jose in my Spring Boot app (version 2.2.4.RELEASE)

The end state I want to get into is, depending on a custom HTTP header set in the request, I want to pick which Okta instance my Spring Boot app uses to decode and validate the JWT token.

Ideally I would have two properties in my configuration file as follows:

jwkSetUri.X=https://dev-X.okta.com/oauth2/default/v1/keys
jwtIssuerUri.X=https://dev-X.okta.com/oauth2/default

jwkSetUri.Y=https://dev-Y.okta.com/oauth2/default/v1/keys
jwtIssuerUri.Y=https://dev-Y.okta.com/oauth2/default

I should be able to use a RequestHeaderRequestMatcher to match the header value in the security configuration. What I cannot workout is how to use two different oauth2ResourceServer instances that goes with the security configuration.

Unskilled answered 3/2, 2020 at 0:39 Comment(0)
E
12

With spring boot this is not possible to do out of the box right now. Spring Security 5.3 provides functionality to do this (spring boot 2.2.6 still doesn't support spring security 5.3). Please see following issues:

https://github.com/spring-projects/spring-security/issues/7857
https://github.com/spring-projects/spring-security/pull/7887

It is possible to do manual configuration of resource server to use multiple identity providers, by following links that i have provided. Provided links are mainly for spring boot webflux development. For basic spring boot web development please see this video:

https://www.youtube.com/watch?v=ke13w8nab-k

Ejaculate answered 5/5, 2020 at 14:23 Comment(3)
Can you please help explain how this can be achieved by Spring-boot 2.3 that supports the above Spring Security 5.3?Lonnalonnard
docs.spring.io/spring-security/site/docs/5.3.0.RELEASE/… just wait few secs when visiting url, it will jump directly to section with multiple identity providers. While it's easy to do, you may still want to use manual configuration to bring custom behavior (i am still manually configuring it in spring boot 2.4).Ejaculate
Tickets are already closed, so most likely it's possible nowPreselector
S
8

This is possible as of Spring security 5.3+ using the JwtIssuerAuthenticationManagerResolver object

Override the configure(HttpSecurity http) inside your configuration class which extends WebSecurityConfigurerAdapter

JwtIssuerAuthenticationManagerResolver authenticationManagerResolver = new JwtIssuerAuthenticationManagerResolver(
            "http://localhost:8080/auth/realms/SpringBootKeyClock",
            "https://accounts.google.com/o/oauth2/auth",
            "https://<subdomain>.okta.com/oauth2/default"
    );

http.cors()
            .and()
            .authorizeRequests()
            .antMatchers(HttpMethod.GET, "/user/info", "/api/foos/**")
            .hasAnyAuthority("SCOPE_email")
            .antMatchers(HttpMethod.POST, "/api/foos")
            .hasAuthority("SCOPE_profile")
            .anyRequest()
            .authenticated()
            .and()
            .oauth2ResourceServer(oauth2 -> oauth2.authenticationManagerResolver(authenticationManagerResolver));
Sickler answered 27/11, 2020 at 13:29 Comment(3)
This looks useful. Can some one answer to this question too which is regarding change the spring security configuration at runtime and lazy loading security configuration at runtime #68831757Flita
I know this is a year old BUT I'm wondering how can I use the above way for multiple issuers and validate the audience with JwtDecoder jwtDecoder() when there is more than one issuer? thanksFilial
This answer saved my time, thanks.Wake
W
2

Step 1: Create a custom AuthenticationManagerResolver

    @Component
    public class TenantAuthenticationManagerResolver implements
        AuthenticationManagerResolver<HttpServletRequest> {private final Map<String, String> tenants;
      private final Map<String, AuthenticationManager> authenticationManagers = new HashMap<>();
    
      private final BearerTokenResolver resolver = new DefaultBearerTokenResolver();
    
      public TenantAuthenticationManagerResolver() {
        this.tenants = new HashMap<>();
      }
    
      public TenantAuthenticationManagerResolver(String tenantIds, String jwkUris) {
        List<String> tenantList = Arrays.asList(tenantIds.split(","));
        List<String> issuerList = Arrays.asList(jwkUris.split(","));
    
        this.tenants = IntStream.range(0, Math.min(tenantList.size(), issuerList.size()))
            .boxed()
            .collect(Collectors.toMap(tenantList::get, issuerList::get));
      }
    
      @Override
      public AuthenticationManager resolve(HttpServletRequest request) {
        return this.authenticationManagers.computeIfAbsent(toTenant(request), this::fromTenant);
      }
    
      private String toTenant(HttpServletRequest request) {
        String token = this.resolver.resolve(request);
        try {
          return (String) JWTParser.parse(token).getJWTClaimsSet().getClaim(TENANT_ID);
        } catch (Exception e) {
          throw new IllegalArgumentException(e);
        }
      }
    
      private AuthenticationManager fromTenant(String tenant) {
        return Optional.ofNullable(this.tenants.get(tenant))
            .map(issuer -> NimbusJwtDecoder.withJwkSetUri(issuer).build())
            .map(JwtAuthenticationProvider::new)
            .orElseThrow(() -> new IllegalArgumentException("unknown tenant"))::authenticate;
      }
    
    }

Step 2: Do the following in your security configuration

  @Value("${issuer.jwkUris}")
  private String jwkUris;

  @Value("${tenantId.tenants}")
  private String tenantIds;

  @Bean
  @Qualifier("tenantIds")
  public String tenantIds() {
    return tenantIds;
  }

  @Bean
  @Qualifier("jwkUris")
  public String jwkUris() {
    return jwkUris;
  }

  @Bean
  public TenantAuthenticationManagerResolver authenticationManagerResolver() {
    return new TenantAuthenticationManagerResolver(tenantIds(), jwkUris());
  }

  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(authorizeRequests -> authorizeRequests.anyRequest().authenticated())
        .exceptionHandling(exceptionHandling -> exceptionHandling.authenticationEntryPoint(
            restAuthenticationEntryPoint()))
        .oauth2ResourceServer(
            oauth2ResourceServer -> oauth2ResourceServer.authenticationManagerResolver(authenticationManagerResolver()));
    return http.build();
  }
Walleye answered 26/8, 2023 at 23:58 Comment(0)
C
0

Another solution i found does not use the AuthenticationManagerResolver, but simply adds a 2nd ("manually" configured) JwtAuthenticationProvider to the security chain:

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    var jwtDecoder = NimbusJwtDecoder.withPublicKey(publicKey).build();
    var jwtAuthenticationProvider = new JwtAuthenticationProvider(jwtDecoder);
    return http
            // one manually configured JwtAuthProvider
            .authenticationProvider(jwtAuthenticationProvider)
            // one automagically configured one
            .oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
            .authorizeHttpRequests(...)
            .build();
}

This way, both providers are tried and the first one accepting the bearer token will allow the access. (Of course, the decoder can be created for jwkSetUrl as well.)

Chivaree answered 22/3 at 17:49 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.