Search code examples
spring-oauth2

OAuth2 Spring Security With Exposing Multiple API's As Multiple Resource Servers


I am using Spring Security OAuth2 support to expose multiple API's as Resource Servers from a single web application. We would like to have each API require different "audience" so different programs can access different API's. I was setting up separate SecurityFilterChains for each API (same issuerURI but different audience). With this configuration, each security filter chain has a different Bearer Token Filter. The problem I am seeing is that any OAuth2 incoming request is picked up by one of the security filter chains (based on order) and since the correct corresponding Authentication type, it tries to process. However, if the audience doesn't not match the value for that API, it throws an error and the other API's aren't attempted. It appears that the Bearer Token Filter is before the request matchers so it is processing messages that match another API URL.

After some research, it appears that an alternative implementation of AuthenticationManagerResolver is needed (instead of JwtIssuerAuthenticationManagerResolver) that looks for audience match also. Alternatively, I guess we could look at placing a Request match before the Bearer Token Filter.

Any thoughts or suggestions?


Solution

  • I think that your initial idea with one SecurityFilterChain bean par "API" is the right one. Maybe you're just lacking a securityMatcher for each (but the last one in @Order so that it acts as default).

    It is very likely that you could define the security matchers based on just a path-prefix.

    Maybe, if path is not enough and if all access tokens are JWTs, you could try to match requests on the access token aud claim too, but the 1st approach is certainly easier.

    Here is a sample with the two different approches:

    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    SecurityFilterChain audxFilterChain(
            HttpSecurity http,
            @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") URI issuer,
            @Value("${audx}") String audx) throws Exception {
        // path based security-matcher
        http.securityMatcher("/api/x/**");
    
        http.oauth2ResourceServer(oauth2 -> {
            oauth2.jwt(jwt -> {
                final var issValidator = JwtValidators.createDefaultWithIssuer(issuer.toString());
                final var audValidator = new JwtClaimValidator<List<String>>(JwtClaimNames.AUD, (aud) -> aud != null && aud.contains(audx));
                final var validator = new DelegatingOAuth2TokenValidator<>(List.of(issValidator, audValidator));
    
                final var decoder = NimbusJwtDecoder.withIssuerLocation(issuer.toString()).build();
                decoder.setJwtValidator(validator);
    
                jwt.decoder(decoder);
            });
        });
    
        http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        http.csrf(csrf -> csrf.disable());
    
        return http.build();
    }
    
    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE + 1)
    SecurityFilterChain audyFilterChain(HttpSecurity http, @Value("${audy}") String audy) throws Exception {
        // JWT claim based security-matcher
        http.securityMatcher((HttpServletRequest request) -> {
            final var hasAudy = Optional.ofNullable(request.getHeader(HttpHeaders.AUTHORIZATION))
                    .filter(StringUtils::hasText)
                    .filter(auth -> auth.toLowerCase().startsWith("bearer "))
                    .map(auth -> auth.substring(7))
                    .map(bearer -> {
                        try {
                            final var jwt = JWTParser.parse(bearer);
                            return jwt.getJWTClaimsSet().getAudience() != null && jwt.getJWTClaimsSet().getAudience().contains(audy);
                        } catch (ParseException e) {
                            return false;
                        }
                    }).orElse(false);
            return hasAudy && someOtherConditionToMatchTheRequestAsQueryingThePartOfYourApiExposedToAudy;
        });
    
        http.oauth2ResourceServer(oauth2 -> {
            // audience is already validated in the matcher
            oauth2.jwt(Customizer.withDefaults());
        });
    
        http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        http.csrf(csrf -> csrf.disable());
    
        return http.build();
    }
    
    @Bean
    @Order(Ordered.LOWEST_PRECEDENCE)
    SecurityFilterChain defaultFilterChain(HttpSecurity http) throws Exception {
        // it's generally a good idea to define a default filter-chain for requests that were matched 
        // by none of the security matchers from filter-chains with higher precedence
        http.authorizeHttpRequests(requests -> requests.anyRequest().denyAll());
    
        http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        http.csrf(csrf -> csrf.disable());
    
        return http.build();
    }
    

    Note that only the filter-chain with the highest order (lowest precedence) hasn't a security matcher.

    Also note that someOtherConditionToMatchTheRequestAsQueryingThePartOfYourApiExposedToAudy can be anything from the request: its path as in the 1st filter-chain, the HTTP verb (like audy can do only OPTIONS and GET operations), some headers matching a pattern, etc.