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:
SCOPE
(by JWT inspection).ROLE
of the user who authorized the client.What I did:
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);
}
};
}
{
"sub":"AssortmentExtender",
"aud":"assortment-client",
"nbf":1689578763,
"auth":"ROLE_ADMIN",
"scope":[
"assortment.write"
],
"iss":"http://auth-server:9000",
"exp":1689579063,
"iat":1689578763
}
@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:
@PreAuthorize
rejects the Client request. 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:
Question:
@PreAuthorize
checking something else than the principal claims?
(I've already tried various variants, including role
, roles
, as discussed here)EDIT:
@PreAuthorize
does not acutally access the claims, but the authorities
extracted from the JWT structure. By default it only extracts scopes, and prefixes each entry with SCOPE_
.JwtAuthenticationConverter
. However, I am lost as of how to achieve this, notably since the jwt().jwtAuthenticationConverter(...)
addendum for the SecurityConfig
appears to be deprecated now.EDIT2
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;
}
scope
claims.scope
rules with the custom roles
claim, but unfortunatley this throws a cast
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.
I figured out how to fuse multiple JWT claims to a list of authorities.
In essence, only two things were needed:
import java.util.ArrayList;
import java.util.Collection;
import java.util.stream.Collectors;
import java.util.Collections;
import java.util.stream.Stream;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
/**
* This is a custom converter to extract all entries of two Jwt claims "scope" and "role". All
* findings are prefixed with the respective "SCOPE_" and "ROLE_" prefixes, and then wrapped up as a
* list of authorities, which can be processed by Springs SPeL in @PreAuthorize annotations. This
* implementation is based on: https://stackoverflow.com/a/58234971/13805480
*/
public class FusedClaimConverter implements Converter<Jwt, AbstractAuthenticationToken> {
// The fused converter internally fuses the outputs of two converters.
// One component is the default converter (extracting scp/scope claim information).
// So we create one instance of this off-the-shelf convert for later use.
private final JwtGrantedAuthoritiesConverter defaultGrantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
/**
* This method provides the second component of our custom converter. It is a manual
* implementation that searches the jwt for a custom "role" claim. If found, all entries are
* prefixed with "ROLE_" and returned as list of authorities.
*
* @param jwt as the json web token to analyze for "role" claim entries.
* @return collection of granted authorities extracted from the jwt.
*/
private static Collection<? extends GrantedAuthority> extractResourceRoles(final Jwt jwt) {
ArrayList<String> resourceAccess = jwt.getClaim(
"role"); // <- specify here whatever additional jwt claim you wish to convert to authority
if (resourceAccess != null) {
// Convert every entry in value list of "role" claim to an Authority
return resourceAccess.stream().map(x -> new SimpleGrantedAuthority("ROLE_" + x))
.collect(Collectors.toSet());
}
// Fallback: return empty list in case the jwt has no "role" claim.
return Collections.emptySet();
}
/**
* This is the main converter method to override. In essence here we provide a custom
* implementation that concatenates the authority lists generated from two respective conterters.
* One is the off-the-shelf default converter that operates on the "scp"/"scope" claim. The other
* is the converter for our custom claim.
*
* @param source as the json web token to inspect for claims
* @return list of authorities extracted from token, wrapped up in AbstractAuthenticationToken
* object.
*/
@Override
public AbstractAuthenticationToken convert(final Jwt source) {
Collection<GrantedAuthority> authorities =
Stream.concat(defaultGrantedAuthoritiesConverter.convert(source).stream(),
extractResourceRoles(source).stream()).collect(Collectors.toSet());
return new JwtAuthenticationToken(source, authorities);
}
}
SecurityFilterChain
:@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class ResourceServerConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize
// Whatever custom rules...
[...]
.oauth2ResourceServer(oauth2 -> {
oauth2.jwt(jwt -> {
jwt.jwtAuthenticationConverter(new FusedClaimConverter()); // <-- Here the custom converter is registered.
});
});
return http.build();
}
EDIT: For completeness, I've uploaded a well documented runnable application with this configuration on GitHub.