Topic of the Week #oauth #springSecurity #troubleshooting
Currently, I’m working for a customer on a fairly typical project team, and I really enjoy it. My teammates are fun, the work is interesting, and I feel confident in my role. I would like to share my knowledge with others, but I’m not an outgoing person and don’t particularly enjoy giving presentations. I know I should probably work on that, but as a starting point, one of my colleagues suggested I write something instead. I’m not sure if the things I work on are interesting enough for others to want to read about them, but my piano teacher always says that the hardest thing for his students is just trying. It’s not important if I miss a note or if it doesn’t sound perfect—just try, and it will improve with time. So, I’ve decided to pick a topic I’ve been working on recently and write about it. Let’s dive right in.
My team recently started developing a new client application for our backend services. The backend is a Spring Boot application, and the frontend interacts with it via REST and JSON while authenticating using OAuth. The REST API already existed, so our task was to develop the new client app and make some minor adjustments to the backend service. After a few weeks, the first feature of our new frontend was nearly complete, and we tested it against our backend service for the first time. We should have done this much sooner, but that’s another story. So we opened our app to trigger the first api call in our test environment. I like these moments – they are exciting and the first integrative tests almost always fail. Because we forgot something, or things just behave differently compared to local testing with our mock services. So we pushed the button and nothing happened. I expected some kind of error message, but instead, we had to open the browser console for a closer look. And there it was the problem we did not encounter during local testing:
403 Forbidden
The first issue was that our frontend code didn’t handle this kind of error response correctly. The implementation expected an error code from the backend, which would be mapped to an appropriate message for the user. However, since the 403 response contained no response body, the code failed with a “cannot read property of undefined” error message. I think we simply forgot to implement error handling for responses without a response body, because our local mocks always return a nicely formatted error object.
The second, and far bigger, mystery was the 403 error itself. So, what does the 403 statuscode mean? According to the RFC specification, it means that “The request requires higher privileges than provided by the access token”
Our application authenticates users via OAuth. For those unfamiliar with OAuth, here’s a very short explanation:
Instead of our client app handling user credentials directly, our client redirects users to an authorization server (that runs somewhere else) to log in. Once the authorization server grants permission, our client application receives an access token. This means that our client application never handles the user’s credentials directly. With the access token, we can make API calls to our backend service. Instead of sending an Authorization header with basic auth credentials, we send an Authorization header with an OAuth token (a JSON Web Token, or JWT), which looks something like this:
Authorization: Bearer eyJraWQiOiJiZGVhZTczOC1jODkzLTQ2N2UtOTYwYS02Mz.....
And it could contain the following information, also known as claims: (You can decode the JWT token, for example, using the JWT Analyzer IntelliJ plugin.)
{
"sub" : "userid123", -> Subject of the token, aka the userid
"aud" : "cool-backend-123", -> Audience for the access token, our backend service
"roles" : [ "COOL_API_READ_ACCESS" ], -> user roles
"iss" : "http://authorization-server", -> The authorization server that issued the token
"exp" : 1740136333, -> expiration date
"iat" : 1740136033, -> timestamp the token was issued at
...
}
This is just an example. We cannot have a look at the real token. Our backend service verifies the signature, issuer, and audience of the token and ensures that it hasn’t expired. This validation is handled by Spring Security. If the validation fails, the service returns a “401 Unauthorized” response. Since we received a 403 instead of a 401, we knew all these things must be valid. The token also includes the user’s roles inside the “roles” claim. If the roles are not valid, we would receive a 403 status code. So that was the next thing we checked. Our service verifies roles through the Spring Security configuration. Our API should only be accessible to users with a specific role, so we configured it like this:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.authorizeHttpRequests((requests) -> requests.requestMatchers("/coolApi")
.hasRole("COOL_API_READ_ACCESS"))
...
}
Now, we needed to verify whether the OAuth token actually included the required role. To do this, we set the Spring Security log level to TRACE and checked the logs.
org.springframework.security: TRACE
Since we deploy via Cloud Foundry, we can easily change the log level in the Cloud Foundry UI without restarting the service, which is quite handy. The following log message seemed promising:
DEBUG 6832 --- [nio-8090-exec-1] .s.r.w.a.BearerTokenAuthenticationFilter :
Set SecurityContextHolder to JwtAuthenticationToken
[Principal=org.springframework.security.oauth2.jwt.Jwt@41039b7b, Credentials=[PROTECTED],
Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=127.0.0.1,
SessionId=null], Granted Authorities=[ROLE_COOL_API_READ_ACCESS]]
The “Granted Authorities=[ROLE_COOL_API_READ_ACCESS]” part contains the roles extracted from the token. That mostly looks like the role we configured in the spring security config, but what about the “ROLE_” prefix? How exactly does Spring Security parse roles from the OAuth token?
By default, Spring Security looks for a “scp” or “scope” claim inside the OAuth token and converts these scopes into granted authorities. These authorities can then be matched with the .hasAuthority(“…”) matcher. However, our authorization server does not use the “scp” or “scope” claim. It uses the “roles” claim instead. There’s a difference in meaning between “scope” and “role.” The scope defines the permissions granted by the user for their resources, while the role defines the user’s role within the application and is typically stored somewhere. Our application only uses roles. Since Spring Security does not parse the “roles” claim by default, we need to specify where to find the authorities claim:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.authorizeHttpRequests((requests) -> requests.requestMatchers("/coolApi")
.hasRole("COOL_API_READ_ACCESS"))
.oauth2ResourceServer((oauth2) -> oauth2.jwt((jwt) ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))
.build();
}
private JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthConverter = new JwtGrantedAuthoritiesConverter();
grantedAuthConverter.setAuthoritiesClaimName("roles");
grantedAuthConverter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter authConverter = new JwtAuthenticationConverter();
authConverter.setJwtGrantedAuthoritiesConverter(grantedAuthConverter);
return authConverter;
}
We also set the authority prefix to “ROLE_”, which is why the granted authorities in our log message Granted Authorities=[ROLE_COOL_API_READ_ACCESS]
contains the “ROLE_” prefix. Spring security adds this prefix after parsing the roles claim from the token.
This is important because the implementation of hasRole(…) automatically adds the same prefix. If we look at our log message again Granted Authorities=[ROLE_COOL_API_READ_ACCESS]
we can see that the roles extracted from the token match those defined in the security config .(‘COOL_API_READ_ACCESS’)
without the “ROLE_” prefix. However, because Spring Security automatically adds the “ROLE_” prefix inside hasRole, the role matches.
So unfortunately, that’s not the issue behind the 403 status code. What other reasons are there? I think now is a good time to post this blog and gather some feedback. And if the topic is interesting enough, the real cause of the 403 statuscode will be revealed in “Topic of the Week: Part Two.” Stay tuned!
Kommentare
Jens
Dear Julia, I really enjoy working with you.
Thank you so much for this blog post, I'm really looking forward to the explanation in the second part.
Best regards