Retrieve Keycloak Roles in reactive Spring Gateway security

1.5k views Asked by At

I migrate from Zuul Gateway to Spring Gateway. This forced me to abandon Servlets for Webflux. I use KeyCloak and KeyCloak roles for authentication and authorization.

There is no official reactive KeyCloak implementation, so I use Spring OAuth2 instead. It works fine apart from retrieving the roles.

I cannot use servlet interceptors, because servlets are not allowed by WebFlux. Also, it seems Spring Gateway in general does not allow intercepting response bodies.

Thus my problem remains: How do I retrieve KeyCloak roles in Spring Gateway, so that they can be used by its security?

Here is some sample code I use: In class SecurityConfig.java:

@Bean public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { http.csrf().disable().authorizeExchange(exchanges -> exchanges.pathMatchers("/**").hasAnyRole("DIRECTOR")); }

application.yml:

spring.security.oauth2.client.provider.keycloak.issuer-uri: ..../realms/default

2

There are 2 answers

0
Dave On

I am having the same problem myself. One of the problems I am getting its getting copies of things like the JWT tag i.e. the text that Keycloak has encode you settings

    @GetMapping("/whoami")
    @ResponseBody
    public Map<String, Object> index(
            @RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient authorizedClient,
                    Authentication auth) {
            log.error("XXAuth is {}",auth);
            log.error("XXClient is {}", authorizedClient.getClientRegistration());
            log.error("XXClient access  is {}", authorizedClient.getAccessToken());
            log.error("Token {}",authorizedClient.getAccessToken().getTokenValue());
   }

This code will get you some of the values that are part of the conversation, the Token part is the JWT token, you can copy and paste that into jwt.io and find out what what Keycloak has actually sent.

This normally looks like

{
  "exp": 1622299931,
  "iat": 1622298731,
  "auth_time": 1622298258,
  "jti": "635ca59f-c87b-40da-b4ae-39774ed8098a",
  "iss": "http://clunk:8080/auth/realms/spring-cloud-gateway-realm",
  "sub": "6de0d95f-95b0-419d-87a4-b2862e8d0763",
  "typ": "Bearer",
  "azp": "spring-cloud-gateway-client",
  "nonce": "2V8_3siQjTOIRbfs68BHwzvz3-dWeqXGUultzhJUWrA",
  "session_state": "dd226823-90bc-429e-9cac-bb575b7d4fa0",
  "acr": "0",
  "realm_access": {
      "roles": [
          "ROLE_ANYONE"
      ]
  },
  "resource_access": {
      "spring-cloud-gateway-client": {
          "roles": [
              "ROLE_ADMIN_CLIENT"
          ]
      }
  },
  "scope": "openid email profile roles",
  "email_verified": true,
  "preferred_username": "anon"

}

As you can see Keycloak supports two different types of ROLE tokens, but they are not defined in top level, but under realm_access and resource_access, the difference being resource access defines ROLE that are part of a resource and real_access defines roles that are defined across all realms.

To get these values defined, its necessary to define a Mapper, as follows enter image description here

To load these values in to Spring security you need to define a userAuthoritiesMapper Bean and export the settings found in the attributes as SimpleGrantedAuthority, as follows.

package foo.bar.com;

import lombok.extern.slf4j.Slf4j;
import net.minidev.json.JSONArray;
import net.minidev.json.JSONObject;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;

@Slf4j
@EnableWebFluxSecurity
@EnableReactiveMethodSecurity

public class RoleConfig {
        

        
        @Bean
        GrantedAuthoritiesMapper userAuthoritiesMapper() {
                String ROLES_CLAIM = "roles";
                return authorities -> {
                        
                        Set<GrantedAuthority> mappedAuthorities = new HashSet<>();
                        
                        for (Object authority : authorities) {
                                boolean isOidc = authority instanceof OidcUserAuthority;
                                
                                if (isOidc) {
                                        log.error("Discovered an Oidc type of object");
                                        var oidcUserAuthority = (OidcUserAuthority) authority;
                                        java.util.Map<String, Object> attribMap = oidcUserAuthority.getAttributes();
                                        JSONObject jsonClaim;
                                        for (String attrib : attribMap.keySet()) {
                                                log.error("Attribute name  {}  type {} ", attrib, attrib.getClass().getName());
                                                Object claim = attribMap.get(attrib);
                                                if (attrib.equals("realm_access")) {
                                                        log.error("Define on roles for  entire client");
                                                        
                                                        jsonClaim = (JSONObject) claim;
                                                        if (!jsonClaim.isEmpty()) {
                                                                log.error("JobClaim is {}", jsonClaim);
                                                                Object roleStr = jsonClaim.get("roles");
                                                                if (roleStr != null) {
                                                                        log.error("Role String {}", roleStr.getClass().getName());
                                                                        JSONArray theRoles = (JSONArray) roleStr; //jsonClaim.get("roles");
                                                                        for (Object roleName : theRoles) {
                                                                                log.error("Name {} ", roleName);
                                                                        }
                                                                        
                                                                }
                                                        }
                                                }
                                                if (attrib.equals("resource_access")) {
                                                        log.error("Unique to attrib client");
                                                        jsonClaim = (JSONObject) claim;
                                                        if (!jsonClaim.isEmpty()) {
                                                                log.error("Job is {}", jsonClaim);
                                                                
                                                                String clientName = jsonClaim.keySet().iterator().next();
                                                                log.error("Client name {}", clientName);
                                                                JSONObject roleObj = (JSONObject) jsonClaim.get(clientName);
                                                                Object roleNames = roleObj.get("roles");
                                                                log.error("Role names {}", roleNames.getClass().getName());
                                                                JSONArray theRoles = (JSONArray) roleObj.get("roles");
                                                                for (Object roleName : theRoles) {
                                                                        log.error("Name {} ", roleName);
                                                                }
                                                                
                                                        }
                                                }
                                                
                                        }
                                        
                                        var userInfo = oidcUserAuthority.getUserInfo();
                                        log.error("UserInfo {}", userInfo);
                                        for (String key : userInfo.getClaims().keySet()) {
                                                log.error("UserInfo keys {}", key);
                                        }
                                        if (userInfo.containsClaim(ROLES_CLAIM)) {
                                                var roles = userInfo.getClaimAsStringList(ROLES_CLAIM);
                                                mappedAuthorities.addAll(generateAuthoritiesFromClaim(roles));
                                        } else {
                                                log.error("userInfo DID NOT FIND A claim");
                                        }
                                } else {
                                        
                                        var oauth2UserAuthority = (SimpleGrantedAuthority) authority;
                                        log.error("Authority name " + authority.getClass().getName());
                                }
                        }
                        
                        return mappedAuthorities;
                };
        }
        
        private Collection<GrantedAuthority> generateAuthoritiesFromClaim(Collection<String> roles) {
                return roles.stream()
                        .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
                        .collect(Collectors.toList());
        }
}

Please note this code is based on a sample found at OAuth2 Login with custom granted authorities from UserInfo The access to Attributes is my own work.

Note an error message will be generated at the highest level if no realm_access or resource_access is found, as I assume that wanting to decode a Keycloak reference is the reason for using this code.

When working correctly, it generates the following output

2021-05-29 15:32:11.249 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Discovered an Oidc type of object
2021-05-29 15:32:11.249 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Attribute name  at_hash  type java.lang.String 
2021-05-29 15:32:11.249 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Attribute name  sub  type java.lang.String 
2021-05-29 15:32:11.249 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Attribute name  resource_access  type java.lang.String 
2021-05-29 15:32:11.249 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Unique to attrib client
2021-05-29 15:32:11.249 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Job is {"spring-cloud-gateway-client":{"roles":["ROLE_ADMIN_CLIENT"]}}
2021-05-29 15:32:11.249 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Client name spring-cloud-gateway-client
2021-05-29 15:32:11.249 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Role names net.minidev.json.JSONArray
2021-05-29 15:32:11.249 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Name ROLE_ADMIN_CLIENT 
2021-05-29 15:32:11.249 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Attribute name  email_verified  type java.lang.String 
2021-05-29 15:32:11.249 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Attribute name  iss  type java.lang.String 
2021-05-29 15:32:11.249 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Attribute name  typ  type java.lang.String 
2021-05-29 15:32:11.249 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Attribute name  preferred_username  type java.lang.String 
2021-05-29 15:32:11.249 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Attribute name  nonce  type java.lang.String 
2021-05-29 15:32:11.249 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Attribute name  aud  type java.lang.String 
2021-05-29 15:32:11.249 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Attribute name  acr  type java.lang.String 
2021-05-29 15:32:11.249 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Attribute name  realm_access  type java.lang.String 
2021-05-29 15:32:11.249 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Define on roles for  entire client
2021-05-29 15:32:11.249 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : JobClaim is {"roles":["ROLE_ANYONE"]}
2021-05-29 15:32:11.249 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Role String net.minidev.json.JSONArray
2021-05-29 15:32:11.249 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Name ROLE_ANYONE 
2021-05-29 15:32:11.250 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Attribute name  azp  type java.lang.String 
2021-05-29 15:32:11.250 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Attribute name  auth_time  type java.lang.String 
2021-05-29 15:32:11.250 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Attribute name  exp  type java.lang.String 
2021-05-29 15:32:11.250 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Attribute name  session_state  type java.lang.String 
2021-05-29 15:32:11.250 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Attribute name  iat  type java.lang.String 
2021-05-29 15:32:11.250 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Attribute name  jti  type java.lang.String 
2021-05-29 15:32:11.250 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : UserInfo org.springframework.security.oauth2.core.oidc.OidcUserInfo@8be9a0b8
2021-05-29 15:32:11.250 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : UserInfo keys sub
2021-05-29 15:32:11.250 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : UserInfo keys email_verified
2021-05-29 15:32:11.250 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : UserInfo keys preferred_username
2021-05-29 15:32:11.250 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : userInfo DID NOT FIND A claim
2021-05-29 15:32:11.252 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Authority name org.springframework.security.core.authority.SimpleGrantedAuthority
2021-05-29 15:32:11.252 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Authority name org.springframework.security.core.authority.SimpleGrantedAuthority
2021-05-29 15:32:11.252 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Authority name org.springframework.security.core.authority.SimpleGrantedAuthority
2021-05-29 15:32:11.252 ERROR 7394 --- [or-http-epoll-5] com.jdriven.gateway.RoleConfig           : Authority name org.springframework.security.core.authority.SimpleGrantedAuthority
2021-05-29 15:32:11.252 DEBUG 7394 --- [or-http-epoll-5] o.s.w.r.f.client.ExchangeFunctions       : [34ff3355] Cancel signal (to close connection)
2021-05-29 15:32:11.252 DEBUG 7394 --- [or-http-epoll-5] o.s.w.r.f.client.ExchangeFunctions       : [1b083d68] Cancel signal (to close connection)
2021-05-29 15:32:11.254 DEBUG 7394 --- [or-http-epoll-5] ebSessionServerSecurityContextRepository : Saved SecurityContext 'SecurityContextImpl [Authentication=OAuth2AuthenticationToken [Principal=Name: [anon], Granted Authorities: [[ROLE_USER, SCOPE_email, SCOPE_openid, SCOPE_profile, SCOPE_roles]], User Attributes: [{at_hash=GCz2JybWiLc-42ACnjLJ6w, sub=6de0d95f-95b0-419d-87a4-b2862e8d0763, resource_access={"spring-cloud-gateway-client":{"roles":["ROLE_ADMIN_CLIENT"]}}, email_verified=true, iss=http://clunk:8080/auth/realms/spring-cloud-gateway-realm, typ=ID, preferred_username=anon, nonce=2V8_3siQjTOIRbfs68BHwzvz3-dWeqXGUultzhJUWrA, aud=[spring-cloud-gateway-client], acr=0, realm_access={"roles":["ROLE_ANYONE"]}, azp=spring-cloud-gateway-client, auth_time=2021-05-29T14:24:18Z, exp=2021-05-29T14:52:11Z, session_state=dd226823-90bc-429e-9cac-bb575b7d4fa0, iat=2021-05-29T14:32:11Z, jti=7d479a85-d76e-4930-9c86-b384a56d7af5}], Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[]]]' in WebSession: 'org.springframework.web.server.session.InMemoryWebSessionStore$InMemoryWebSession@69c3d462'
1
user14344662 On

@Dave Thank you for reminding me this question. I have since found a workaround in WebFlux. I have overriden ReactiveOAuth2UserService. By default it has two flavors a OAuth one and a Oidc one. In my case I have overriden the Oidc one:

@Component public class ReactiveKeycloakUserService extends OidcReactiveOAuth2UserService {
 @Override
 public Mono<OidcUser> loadUser(OidcUserRequest userRequest) throws ... {
  // Call super and then replace result with roles
 }
}

Spring will inject my instance instead of the default one. From userRequest you can retrieve the roles and after calling the same method on superclass you can intercept the result and add the roles on it.