The @EnableResourceServer and the library it comes from org.springframework.security.oauth.boot:spring-security-oauth2-autoconfigure was deprecated a while ago but some projects still use it. I spent a bit of time replacing this library in our current system and want to share a little bit of what we did to replace and that may help you when replacing yours as well.

In Oauth2 terms a resource server is your api service that handles a specific request after it has been authenticated. It uses the access token sent in the request to check if that specific client has access to the service’s resources.

In this post we are talking only about the resource server, so we are considering that your authorization server already generated the token and the client are using that token to issue a request.

In our case, we were using a JWT token. Our API proxy receives a request with an opaque token and generates an internal JWT which contains the information of the principal and authorities so the service can decode that JWT, check its signature, and extract the information to check if the request in question is authorized.

Old code

this is what we have before

in our build.gradle

implementation "org.springframework.security.oauth.boot:spring-security-oauth2-autoconfigure:2.6.8"

and our resource server Spring configuration

public class ResourceOAuthConfiguration implements ResourceServerConfigurer {
    
    @Value("${security.whitelisted_urls:#{null}}")
    private String[] whitelistedUrls;

    @Inject
    private AuthProperties authProperties;

    @Bean
    public ResourceServerTokenServices remoteTokenServices() {
        return new CustomUserInfoTokenServices(authProperties.getClientId(), authProperties.getJwtPassword());
    }

    @Bean
    public SecurityEvaluationContextExtension securityEvaluationContextExtension() {
        return new DefaultSecurityEvaluationContextExtension();
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers(whitelistedUrls)
                .permitAll()
                .and()
                .authorizeRequests()
                .anyRequest()
                .authenticated();
    }
}

the ResourceServerTokenServices was also deprecated, and it was the thing that was really doing the job of receiving the JWT token and setting the Security Context.

public class CustomUserInfoTokenServices implements ResourceServerTokenServices {

    private final String clientId;
    private final String jwtPassword;

    @Autowired(required = false)
    private HttpServletRequest request;

    public CustomUserInfoTokenServices(String clientId, String jwtPassword) {
        this.clientId = clientId;
        this.jwtPassword = jwtPassword;
    }

    @Override
    public OAuth2Authentication loadAuthentication(String accessToken) {
        return extractAuthentication();
    }

    private OAuth2Authentication extractAuthentication() {
        MyPrincipal myPrincipal = of(request.getHeader("Authorization").split(" "))
                .skip(1)
                .findFirst()
                .map(this::userInfo)
                .orElseThrow(() -> new IllegalStateException("Failed to read Bearer token"));

        List<GrantedAuthority> authorities = myPrincipal.getRoles().stream()
                .map(SimpleGrantedAuthority::new)
                .collect(toList());

        OAuth2Request request = new OAuth2Request(null, this.clientId, null, true, new HashSet<>(myPrincipal.getScopes()),
                null, null, null, null);

        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(myPrincipal, "N/A", authorities);

        return new OAuth2Authentication(request, token);
    }

    private MyPrincipal userInfo(String jwt) {
        Claims body = parseJwt(jwt);

        return MyPrincipal.builder()
                .authType(body.get("authType", String.class))
                .userId(body.get("userId", String.class))
                .externalToken(body.get("externalToken", String.class))
                .externalId(body.get("externalId", String.class))
                .build();
    }

    private Claims parseJwt(String jwt) {
        return Jwts.parser()
                .setSigningKey(jwtPassword)
                .parseClaimsJws(jwt)
                .getBody();
    }
}

Basically, we were using the loadAuthentication to extract the Authorization header to get the bearer token.

This JWT was then converted to MyPrincipal object which was being used in the return new OAuth2Authentication() so it could set the security context of the application.

And this way, the application was authorized.

Replacing with Spring Security

Now, to replace this code with the Spring Security functionalities, we removed the

implementation "org.springframework.security.oauth.boot:spring-security-oauth2-autoconfigure:2.6.8"

and added

implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'

to our build.gradle

the logic of converting the JWT token string was moved to a new Converter, which received a string and returned a MyAuthenticationToken which has a simple logic

public class MyPrincipalJwtConverter implements Converter<Jwt, MyAuthenticationToken> {

    @Override
    public MyAuthenticationToken convert(Jwt jwt) {
        var principal = userInfo(jwt);
        return new MyAuthenticationToken(principal);
    }

    private MyPrincipal userInfo(Jwt jwt) {
        return MyPrincipal.builder()
                .authType(jwt.getClaim("authType"))
                .userId(jwt.getClaim("userId"))
                .roles(parseList(jwt.getClaim("roles")))
                .build();
    }

    private List<String> parseList(Object object) {
        if (object == null) {
            return emptyList();
        }

        if (object instanceof List list) {
            return list;
        } else if (object instanceof String string) {
            return singletonList(string);
        }

        return emptyList();
    }
}

and then, we changed the configuration to

@Configuration
public class WebSecurityConfig {

    @Value("${security.whitelisted_urls:#{null}}")
    private String[] whitelistedUrls;

    @Value("${security.jwt.password}")
    private String jwtPassword;

    @Bean
    JwtDecoder jwtDecoder() {
        byte[] secretKeyBytes = Base64.getDecoder().decode(jwtPassword);

        SecretKey secretKey = new SecretKeySpec(secretKeyBytes, JwsAlgorithms.HS512);

        return NimbusJwtDecoder
                .withSecretKey(secretKey)
                .macAlgorithm(MacAlgorithm.HS512)
                .build();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        return http.authorizeRequests()
                .requestMatchers(whitelistedUrls)
                .permitAll()
                .and()
                .csrf().ignoringRequestMatchers(whitelistedUrls)
                .and()
                .oauth2ResourceServer(oauth2 -> oauth2
                        .jwt(jwt -> jwt.decoder(jwtDecoder())
                                .jwtAuthenticationConverter(new MyPrincipalJwtConverter()))
                )
                .authorizeRequests()
                .anyRequest()
                .authenticated().and().build();
    }
}

The difference here is JwtDecoder bean that will be used to decode the token that comes in the authorization header and we add a new converter so it can use the decoded token.

This configuration now will set the Security Context of the application so we don’t need other external stuff

To test it, we wrote some Spring Tests to validate if all the scenarios were working correctly.

@SpringBootTest(classes = {SecurityApplication.class})
@AutoConfigureMockMvc
@Import({DummyController.class})
public class DummyControllerTest {

    private static final String JWT_TOKEN_VALID = "eyJhbGciOiJIUzUxMiJ9.eyJyb2xlcyI6WyJST0xFX0NMSUVOVCJdLCJhdXRoVHlwZSI6ImFwaSIsImV4cCI6MjcxMTA1MzI3MiwidXNlcklkIjoidXNlcl9pZF8xMjM0NSJ9.By5guJ1PF-BoFR457KW6W5wcp8qclSVdYeXcQDv60L79iCvJs_IHau04XwWJk2hkISC-eXIsZyXOgGcBn2kOhg";
    private static final String JWT_TOKEN_EXPIRED = "eyJhbGciOiJIUzUxMiJ9.eyJyb2xlcyI6WyJST0xFX0NMSUVOVCJdLCJhdXRoVHlwZSI6ImFwaSIsImV4cCI6MTcxMTA1MzI3MiwidXNlcklkIjoidXNlcl9pZF8xMjM0NSJ9.EQ82LhBw1hvQ10LajO7Rl3xBfCjKvaUTvVL8SYV1uMTahG08LFU6R1M0sNGdsFveQ0vXBZj9Di9LKCpNL2scjg";
    private static final String JWT_TOKEN_INVALID = "eyJhbGciOiJIUzUxMiJ9.eyJyb2xlcyI6WyJST0xFX0NMSUVOVCJdLCJhdXRoVHlwZSI6ImFwaSIsImV4cCI6MTcxMTA1MzI3MiwidXNlcklkIjoidXNlcl9pZF8xMjM0NSJ9.y2A9fAI7wUIxN4VpeVQet-OyVwlqA6gvQ9o7NNy67-QSEEzjM8TnDCLUDjK8hDWXQx-QqN3mGlG8jovCbu1Pk3";
    @Autowired
    private MockMvc mockMvc;

    @Test
    void shouldTestWithoutAuthentication() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/api/secure"))
                .andExpect(MockMvcResultMatchers.status().isUnauthorized());
    }

    @Test
    @WithAnonymousUser
    void shouldTestWithAnonymousUser() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/api/secure"))
                .andExpect(MockMvcResultMatchers.status().isUnauthorized());
    }


    @Test
    void shouldTestSecuredEndpointWithTokenExpired() throws Exception {
        MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get("/api/secure")
                .header(HttpHeaders.AUTHORIZATION, "Bearer " + JWT_TOKEN_EXPIRED);

        mockMvc.perform(requestBuilder)
                .andExpect(MockMvcResultMatchers.status().isUnauthorized());
    }

    @Test
    void shouldTestSecuredEndpointWithTokenInvalidSignature() throws Exception {
        MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get("/api/secure")
                .header(HttpHeaders.AUTHORIZATION, "Bearer " + JWT_TOKEN_INVALID);

        mockMvc.perform(requestBuilder)
                .andExpect(MockMvcResultMatchers.status().isUnauthorized());
    }

    @Test
    void shouldTestSecuredEndpointWorkingToken() throws Exception {
        MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get("/api/secure")
                .header(HttpHeaders.AUTHORIZATION, "Bearer " + JWT_TOKEN_VALID)
                .contentType(MediaType.APPLICATION_JSON);

        mockMvc.perform(requestBuilder)
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(result -> {
                    MyPrincipal principal = convertResultInPrincipal(result);

                    assertThat(principal).isNotNull();
                    assertThat(principal.getAuthType()).isEqualTo("api");
                    assertThat(principal.getUserId()).isEqualTo("user_id_12345");
                    assertThat(principal.getRoles()).contains("ROLE_CLIENT");
                });
    }

    @Test
    void testWhitelistedUrlWithAntMatcher() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.post("/api/user/1235/invite"))
                .andExpect(MockMvcResultMatchers.status().isOk());
    }

    private static MyPrincipal convertResultInPrincipal(MvcResult result)
            throws JsonProcessingException, UnsupportedEncodingException {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        return objectMapper.readValue(result.getResponse().getContentAsString(), MyPrincipal.class);
    }
}

One thing that we needed to add that was not in the previous configuration was the disabling of CSRF protection for whitelisted URLs. In some cases, where we had public URLs which received a token, it was blocking so adding

.csrf().ignoringRequestMatchers(whitelistedUrls)

To the WebConfig was enough to fix it.

Summary

I hope this helps you a little bit in the migration and in the improvement of your code. The code for the examples is available in this Github repository

Cheers