I have implemented a simple OAuth2 test app (Authorization Server, Resource Server, Client), based on a Baeldung example (Sample code is on GitHub)
Everything works fine, but I want to add an additional security contraint:
- So far the ResourceServer verifies the Client’s
SCOPE
(by JWT inspection). - I want the ResourceServer to also check the
ROLE
of the user who authorized the client.
What I did:
- By default the ROLE information is not as claim in the JWT, so I added it (in the Authorization Server). It now adds new claim auth (unsure if
auth
is the right keyword):
@Bean
OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer() {
return context -> {
if (context.getTokenType() == OAuth2TokenType.ACCESS_TOKEN) {
Authentication principal = context.getPrincipal();
String enumeratedRoles = principal.getAuthorities().iterator().next().getAuthority();
context.getClaims().claim("auth", enumeratedRoles);
}
};
}
- I debugged the ResourceServer's portected method (the one invoked by client). Both, JWT and authorized.principal carry the additional claim, so far so good:
- JWT bayload, deserialized:
{
"sub":"AssortmentExtender",
"aud":"assortment-client",
"nbf":1689578763,
"auth":"ROLE_ADMIN",
"scope":[
"assortment.write"
],
"iss":"http://auth-server:9000",
"exp":1689579063,
"iat":1689578763
}
- Protected method, debug info:
@PreAuthorize("hasRole('ADMIN')")
@PutMapping("/bookstore/isbns/{isbn}")
public void addBookToAssortment(@RequestBody BookDetailsImpl bookDetails, final @AuthenticationPrincipal
Jwt jwt, Authentication authentication) {
// Whatever protected logic, here I debugged...
}
Debugger fields, for method's authentication parameter correctly shows the ROLE_ADMIN claim:
name = "AssortmentExtender"
...
principal = {Jwt@7362}
headers = {Collections$UnmodifiableMap@7402\ size = 2
claims = {Collections$UnmodifiableMap@7403} size = 8
"sub" -> "AssortmentExtender"
"aud" -> {ArrayList@7428} size = 1
"nbf" -> ‹Instant@74301 #2023-07-17T07:15:33Z"
"auth" -> "ROLE_ADMIN"
"scope" -> {ArrayList@7434} size = 1
"iss" -> "htto://auth-server:9000"
"exp" -> {Instant@7406) "2023-07-17T07:20:33Z"
"iat" -> (Instant@7405) "2023-07-17T07:15:33Z"
tokenValue = "eyJrawQiOil3YTzNTNmOCOxYTAXLTQwNmQtYjczOCthN
issuedAt = (Instant@7405) "2023-07-17T07:15:33Z"
expiresAt = {Instant@74061 2023-07-17T07:20:33Z"
credentials = {Jwt@7362}
What does not work:
- The ResourceServer's
@PreAuthorize
rejects the Client request. - Logger says:
Failed to authorize ReflectiveMethodInvocation: [...] with authorization manager
org.springframework.security.config.annotation.method.configuration.DeferringObservationAuthorizationManager@4b97e0b0 and decision ExpressionAuthorizationDecision [granted=false,
expressionAttribute=hasRole('ADMIN')]
Summary:
- The claim is there, JWT and principal object correctly show the ADMIN_ROLE claim.
- @PreAuthorize rejects the request anyway.
Question:
- Why does PreAuthorize not pick up the 'auth' claim?
- Am I just using the wrong keyword? Is
@PreAuthorize
checking something else than the principal claims? (I've already tried various variants, includingrole
,roles
, as discussed here)
EDIT:
- From what I've found so far, the problem is that
@PreAuthorize
does not acutally access the claims, but theauthorities
extracted from the JWT structure. By default it only extracts scopes, and prefixes each entry withSCOPE_
. - My guess is that I somehow need to register a mechanism that does now the same for my jwt ROLES, and this seems to be possible with a custom
JwtAuthenticationConverter
. However, I am lost as of how to achieve this, notably since thejwt().jwtAuthenticationConverter(...)
addendum for theSecurityConfig
appears to be deprecated now.
EDIT2
- Based on Sebastiaans answer, and this sample converter, I am now able to convert the claim to an authority, using a converter:
private Converter getJwtAuthenticationConverter() {
// create a custom JWT converter to map the "roles" from the token as granted authorities
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName("roles"); // claim as JSON entry in JWT
jwtGrantedAuthoritiesConverter.setAuthorityPrefix(
"ROLE_"); // prefix to be used in authority object
// Return a new Converter object that reflects the above JWTclaim-To-Authorities rules.
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
return jwtAuthenticationConverter;
}
- However unfortunately this seems to overwrite all existing authorities from the
scope
claims. - Based on a github discussion, I tried to write a converter that combines the existing
scope
rules with the customroles
claim, but unfortunatley this throws acast
exception at runtime:
// Throws classcast exception at runtime:
// java.util.LinkedHashSet cannot be cast to class org.springframework.security.authentication.AbstractAuthenticationToken
private Converter getDualJwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter scope = new JwtGrantedAuthoritiesConverter();
scope.setAuthorityPrefix("SCOPE_");
scope.setAuthoritiesClaimName("scope");
JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter();
roles.setAuthorityPrefix("ROLE_");
roles.setAuthoritiesClaimName("roles");
return new DelegatingJwtGrantedAuthoritiesConverter(scope, roles);
}
As far as I can tell, the problem is that the Security configuration jwt.jwtAuthenticationConverter(whateverConverter());
wants a converter of type <Jwt, AbstractAuthToken>
, while the DelegatingJwtGrantedAuthoritiesConverter
is of type <Jwt, Collection<GrantedAuthority>>
.
So ultimately the question is how to write a converter that combines multiple JWT claims into a fused list of authorities.