Search code examples
javaspring-bootspring-securityauthorizationkeycloak

How To resolve Role based authentication using Keycloak using Spring Webflux In ApiGateway


I have decoded Jwt token like that. I added User Realm Role Token Mapper in Client Scope using Keyclock server. Thus we can access that claim directly from jwt.

{
  "exp": 1707597391,
  "iat": 1707597091,
  "jti": "505e8da0-1035-49fc-be74-1c59ab15ac03",
  "iss": "http://localhost:8181/realms/spring-boot-microservices-realm",
  "aud": "account",
  "sub": "bcb69d29-b6a4-4c74-96f5-4e89e3f741a1",
  "typ": "Bearer",
  "azp": "spring-cloud-client",
  "session_state": "f36ff533-9403-4b15-9099-d1679e4afd13",
  "acr": "1",
  "allowed-origins": [
    "/*"
  ],
  "realm_access": {
    "roles": [
      "offline_access",
      "ADMIN",
      "uma_authorization",
      "default-roles-spring-boot-microservices-realm"
    ]
  },
  "resource_access": {
    "account": {
      "roles": [
        "manage-account",
        "manage-account-links",
        "view-profile"
      ]
    }
  },
  "scope": "email profile",
  "sid": "f36ff533-9403-4b15-9099-d1679e4afd13",
  "email_verified": false,
  "roles": [
    "offline_access",
    "ADMIN",
    "uma_authorization",
    "default-roles-spring-boot-microservices-realm"
  ],
  "preferred_username": "admin"
}

Thats here my Security Config file


@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {


    @Bean
    public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) throws Exception{
        http
        .authorizeExchange(exchange -> exchange
                .pathMatchers("/api/product/**").permitAll()
                .pathMatchers("/api/user/**").hasRole("ADMIN")
                .anyExchange().authenticated())
                .oauth2ResourceServer((oauth2) -> oauth2
                .jwt(Customizer.withDefaults()));

        return http.build();
    }



    @Bean
    public JwtAuthenticationConverter con(){
        JwtAuthenticationConverter c = new JwtAuthenticationConverter();
        JwtGrantedAuthoritiesConverter cv = new JwtGrantedAuthoritiesConverter();
        cv.setAuthorityPrefix(""); // Default "SCOPE_"
        cv.setAuthoritiesClaimName("roles"); //Default "scope" or "scp"
        c.setJwtGrantedAuthoritiesConverter(cv);
        return c;
    }


}

I can obtain roles on my Jwt token and i use JwtAuthenticationConverter however still i can not access role based path it throws me 403 Forbidden exception. I didn't understands what is the problem exactly.


Solution

  • Preamble

    You have 3 claims containing roles in your sample token, with the following JSON path (in the solutions below, I expose how to extract all):

    • $.roles
    • $.realm_access.roles
    • $.resource_access.account.roles

    In your conf, you are using hasRole("ADMIN") (which expects a ROLE_ADMIN authority), but without adding a prefix to a Keycloak role which is ADMIN. This won't work. Two options:

    • change hasRole("ADMIN") to hasAuthority("ADMIN")
    • keep hasRole("ADMIN") but add a ROLE_ prefix to Keycloak roles

    JwtAuthenticationConverter is for servlet applications. It won't work in a SecurityWebFilterChain. Instead, configure a ReactiveJwtAuthenticationConverter as http.oauth2ResourceServer((oauth2) -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(reactiveJwtAuthenticationConverter)))

    Simplest Solution

    In addition to spring-boot-starter-oauth2-resource-server (which you should already have), add a dependency to this Spring Boot starter I maintain:

    <dependency>
        <groupId>com.c4-soft.springaddons</groupId>
        <artifactId>spring-addons-starter-oidc</artifactId>
        <version>7.5.3</version>
    </dependency>
    

    Instead of spring.security.oauth2.resourceserver.jwt.*:

    issuer: http://localhost:8181/realms/spring-boot-microservices-realm
    
    com:
      c4-soft:
        springaddons:
          oidc:
            ops:
            - iss: ${issuer}
              authorities:
              - path: $.roles
                prefix: ROLE_
              - path: $.realm_access.roles
                prefix: ROLE_
              - path: $.resource_access.*.roles
                prefix: ROLE_
            resourceserver:
              permit-all:
              - /api/product/**
    
    @Configuration
    public class SecurityConfig {
    
        @Bean
        ResourceServerAuthorizeExchangeSpecPostProcessor accessControl() {
            return spec -> {
                return spec.pathMatchers("/api/user/**").hasRole("ADMIN");
            };
        }
    }
    

    With just spring-boot-starter-oauth2-resource-server

    Change your security configuration to the following:

    @Configuration
    @EnableWebFluxSecurity
    public class SecurityConfig {
    
        @Bean
        SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http, Converter<Jwt, Mono<AbstractAuthenticationToken>> authenticationConverter) throws Exception {
            http.authorizeExchange(exchange -> exchange
                .pathMatchers("/api/product/**").permitAll()
                .pathMatchers("/api/user/**").hasRole("ADMIN")
                .anyExchange().authenticated())
                .oauth2ResourceServer((oauth2) -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(authenticationConverter)));
    
            http.securityContextRepository(NoOpServerSecurityContextRepository.getInstance());
            http.csrf(csrf -> csrf.disable());
            
            return http.build();
        }
    
        @Bean
        ReactiveJwtAuthenticationConverter jwtAuthenticationConverter(Converter<Jwt, Flux<GrantedAuthority>> authoritiesConverter) {
            final var jwtAuthenticationConverter = new ReactiveJwtAuthenticationConverter();
            jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
            return jwtAuthenticationConverter;
        }
        
        static interface ReactiveAuthoritiesConverter extends Converter<Jwt, Flux<GrantedAuthority>> {}
        
        @SuppressWarnings("unchecked")
        @Bean
        ReactiveAuthoritiesConverter authoritiesConverter() {
            return jwt -> {
                final List<String> allRoles = new ArrayList<>();
                final var realmAccess = (Map<String, Object>) jwt.getClaims().getOrDefault("realm_access", Map.of());
                allRoles.addAll((List<String>) realmAccess.getOrDefault("roles", List.of()));
                
                final var resourceAccess = (Map<String, Object>) jwt.getClaims().getOrDefault("resource_access", Map.of());
                for(final var clientId : resourceAccess.keySet()) {
                    final var clientAccess = (Map<String, Object>) resourceAccess.getOrDefault(clientId, Map.of());
                    allRoles.addAll((List<String>) clientAccess.getOrDefault("roles", List.of()));
                }
                
                allRoles.addAll((List<String>) jwt.getClaims().getOrDefault("roles", List.of()));
                
                return Flux.fromStream(allRoles.stream().map(r -> "ROLE_%s".formatted(r)).map(SimpleGrantedAuthority::new));
            };
        }
    }
    

    P.S.

    The configuration is almost the same on a servlet resource server when using "my" starter, but you have to change quite a few interfaces when using only spring-boot-starter-oauth2-resource-server.

    Also, if the number of resource servers behind the gateway increases, you'll have to duplicate quite some configuration code (duplicated code always is a problem on the long term).

    Apparently, your are configuring on the gateway the access control to downstream resource servers. For many reasons, I would not do that and set it on each resource server instead:

    • it is unsafe to have services without security (even if these services are not exposed publicly)
    • keep low coupling between services and the ability to define access control rules involving the accessed resource: things like hasRole('ADMIN') or #user.id == authentication.name which is impossible if you don't know the implementation of a User resource
    • possibility to unit-test access control (see this Baeldung article I wrote)

    If your frontend is a single-page or mobile application, have a look at the OAuth2 BFF pattern and consider configuring the gateway as an OAuth2 client with oauth2Login() (and the TokenRelay filter when routing a request to a resource server). Otherwise, the gateway would probably be better without security at all.