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