Search code examples
javaspringoauth-2.0keycloak

Role-based authentication not working with Keycloak and Java Spring


I have been following this tutorial: https://www.baeldung.com/spring-boot-keycloak to set up my Spring app with Keycloak. The authentication works normally, but when I want to add role-based authentication, for example

.requestMatchers(new AntPathRequestMatcher("/api/v1/user/**")).hasRole("user")

I always get the following error

Bearer error="insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"

I have checked the authorites using

SecurityContextHolder.getContext().getAuthentication().getAuthorities();

and it only has ones that have the SCOPE_ prefix. I have also checked the token and it returns this correctly

 "realm_access": {
    "roles": [
      "offline_access",
      "uma_authorization",
      "default-master-realm",
      "user"
    ]
  },

besides the other fields.

This is my KeycloakConfig class:

package com.motus.core.shared.config.security;

import com.motus.auth.constants.Authority;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
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.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority;
import org.springframework.security.oauth2.core.user.OAuth2UserAuthority;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy;
import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
import org.springframework.security.web.session.HttpSessionEventPublisher;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

import java.util.Collection;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

@Slf4j
@EnableWebSecurity
@Configuration
public class KeycloakConfig {

    private static final String GROUPS = "groups";
    private static final String REALM_ACCESS_CLAIM = "realm_access";
    private static final String ROLES_CLAIM = "roles";

    @Bean
    public SessionRegistry sessionRegistry() {
        return new SessionRegistryImpl();
    }

    @Bean
    protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
        return new RegisterSessionAuthenticationStrategy(sessionRegistry());
    }

    @Bean
    public HttpSessionEventPublisher httpSessionEventPublisher() {
        return new HttpSessionEventPublisher();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http, KeycloakLogoutHandler keycloakLogoutHandler) throws Exception {
        http.authorizeHttpRequests(auth -> auth
                .requestMatchers(new AntPathRequestMatcher("/api/v1/public/**")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/api/v1/auth/**")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/api/v1/admin/**")).hasRole("admin")
                .requestMatchers(new AntPathRequestMatcher("/api/v1/user/**")).hasRole("user")
                .anyRequest().authenticated()
        );
        http.oauth2ResourceServer(oauth2 -> oauth2
                .jwt(Customizer.withDefaults()));
        http.oauth2Login(Customizer.withDefaults())
                .logout(logout -> logout.addLogoutHandler(keycloakLogoutHandler).logoutSuccessUrl("/"));

        http.csrf(AbstractHttpConfigurer::disable);
        http.cors(Customizer.withDefaults());

        return http.build();
    }

    @Bean
    public GrantedAuthoritiesMapper userAuthoritiesMapperForKeycloak() {
        return authorities -> {
            Set<GrantedAuthority> mappedAuthorities = new HashSet<>();
            var authority = authorities.iterator().next();
            boolean isOidc = authority instanceof OidcUserAuthority;

            if (isOidc) {
                var oidcUserAuthority = (OidcUserAuthority) authority;
                var userInfo = oidcUserAuthority.getUserInfo();

                if (userInfo.hasClaim(REALM_ACCESS_CLAIM)) {
                    var realmAccess = userInfo.getClaimAsMap(REALM_ACCESS_CLAIM);
                    var roles = (Collection<String>) realmAccess.get(ROLES_CLAIM);
                    mappedAuthorities.addAll(generateAuthoritiesFromClaim(roles));
                } else if (userInfo.hasClaim(GROUPS)) {
                    Collection<String> roles = userInfo.getClaim(
                            GROUPS);
                    mappedAuthorities.addAll(generateAuthoritiesFromClaim(roles));
                }
            } else {
                var oauth2UserAuthority = (OAuth2UserAuthority) authority;
                Map<String, Object> userAttributes = oauth2UserAuthority.getAttributes();

                if (userAttributes.containsKey(REALM_ACCESS_CLAIM)) {
                    Map<String, Object> realmAccess = (Map<String, Object>) userAttributes.get(
                            REALM_ACCESS_CLAIM);
                    Collection<String> roles = (Collection<String>) realmAccess.get(ROLES_CLAIM);
                    mappedAuthorities.addAll(generateAuthoritiesFromClaim(roles));
                }
            }
            return mappedAuthorities;
        };
    }

    Collection<GrantedAuthority> generateAuthoritiesFromClaim(Collection<String> roles) {
        return roles.stream().map(role -> new SimpleGrantedAuthority("ROLE_" + role)).collect(
                Collectors.toList());
    }
}

It looks like the GrantedAuthoritesMapper is not being invoked, do you know why? Here is what I added in my pom.xml:

<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
        </dependency>

and here is what I added in my application.properties:

spring.security.oauth2.client.registration.keycloak.client-id=${KEYCLOAK_CLIENT_ID:test-backend}
spring.security.oauth2.client.registration.keycloak.authorization-grant-type=${KEYCLOAK_AUTHORIZATION_GRANT_TYPE:password}
spring.security.oauth2.client.registration.keycloak.scope=${KEYCLOAK_SCOPE:openid}
spring.security.oauth2.client.provider.keycloak.issuer-uri=${KEYCLOAK_ISSUER_URI:http://localhost:8080/realms/master}
spring.security.oauth2.client.provider.keycloak.user-name-attribute=${KEYCLOAK_USER_NAME_ATTRIBUTE:username}
spring.security.oauth2.resourceserver.jwt.issuer-uri=${KEYCLOAK_RESOURCE_SERVER_JWT_ISSUER_URI:http://localhost:8080/realms/master}

I obtain the token using http://localhost:8080/realms/master/protocol/openid-connect/token Then I send the request to my app with the obtained token, and I always get the error mentioned above. Does anyone know how can I fix this?


Solution

  • hasRole('user') requires the ROLE_user authority

    Either:

    • use hasAuthority('user')
    • add the ROLE_ prefix when mapping Keycloak roles to Spring Security authorities

    Do not use oauth2ResourceServer and oauth2Login in the same security filter-chain

    Remember that an OAuth2 client consumes resources served by resource servers (these are different actors). In micro-services architectures, it is frequent to have applications with both roles, but for for different resources (which should be secured with different Security(Web)FilterChain beans).

    Spring Boot provides different starters for a reason. I know that the Baeldung tutorial, as well as many others from authors who copy from each other without understanding what they are doing, are using oauth2ResourceServer and oauth2Login in the same security filter-chain, but this is nonsense. Let's get a closer look at the security configuration for the two:

    • oauth2ResourceServer is configuring an OAuth2 resource server which:
      • authorizes requests based on a Bearer token in Authorization header
      • does not need sessions (can and should be stateless)
      • does not need protection against CSRF attacks (because it doesn't use sessions)
      • should return a 401 Unauthorized when the Authorization header is missing or invalid
      • maps authorities in a Converter<Jwt, ? extends AbstractAuthenticationToken> (if using JWT decoding, or customizing the introspector for opaque tokens)
    • oauth2Login is configuring an OAuth2 client with authorization code flow which:
      • authorizes requests based on a session cookie (not a Bearer token in Authorization header)
      • needs protection against CSRF attacks
      • should return a 302 Redirect to login to requests without a valid session
      • maps authorities with a GrantedAuthoritiesMapper (or with a custom OAuth2UserService)

    These requirements are too different to stand in the same Security(Web)FilterChain bean. Also, note from the above that authorities are not mapped the same way on oauth2ResourceServer with JWT decoder, oauth2ResourceServer with (opaque) token introspector, and OAuth2 clients with oauth2Login.

    PS

    You should probably read my tutorials intro to get some more OAuth2 background.

    The additional starter I host in the same repo can help you configure your Spring applications for Keycloak with just properties (these Spring apps being mainly OAuth2 clients with oauth2Login or OAuth2 resource servers)