Missing menuItems when using rolesAllowed

I am attempting to protect some of my Hilla views, and am running into issues. When I use rolesAllowed in the ViewConfig on any page, the view just disappears from the menu.

I can verify that the user has the correct role, and the security policy is being checked when accessing the view through the direct URL (correct). If the user has the role removed, they can no longer access that view (correct).

I cannot quite figure out where the disconnect happens with the menu…
In createMenuItems.js I can see that we are creating the Signal from the value in window.Vaadin?.views and I can also see during a breakpoint that that value in window.Vaadin?.views is missing any of the views that have rolesAllowed configured. I just don’t know why it’s missing from there, and cannot figure out how it is created / filled.

Any help would be greatly appreciated.

I am using Spring Security / stateless auth FYI in case that matters.
Vaadin 24.7.5

Edit: I finally found where the views are being limited. Seems like it’s an issue with falling back to Spring Security to check the roles, and it is using the auth.getAuthorities() which is empty.

I use oauth2 to ensure the user is authenticated, but my authorization is done with roles stored in my database.

Eventually the call stack gets to here:
RouteUtil.java

    private static void filterClientViews(
            Map<String, AvailableViewInfo> configurations,
            HttpServletRequest request) {
        final boolean isUserAuthenticated = request.getUserPrincipal() != null;

        Set<String> clientEntries = new HashSet<>(configurations.keySet());
        for (String key : clientEntries) {
            if (!configurations.containsKey(key)) {
                continue;
            }
            AvailableViewInfo viewInfo = configurations.get(key);
            boolean routeValid = validateViewAccessible(viewInfo,
                    isUserAuthenticated, request::isUserInRole);

which then calls the below function with request::isUserInRole

	private boolean isGranted(String role) {
		Authentication auth = getAuthentication();
		if (this.rolePrefix != null && role != null && !role.startsWith(this.rolePrefix)) {
			role = this.rolePrefix + role;
		}
		if ((auth == null) || (auth.getPrincipal() == null)) {
			return false;
		}
		Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
		if (authorities == null) {
			return false;
		}
		for (GrantedAuthority grantedAuthority : authorities) {
			if (role.equals(grantedAuthority.getAuthority())) {
				return true;
			}
		}
		return false;
	}

And here auth.getAuthorities() is a JWT token that is empty.

Any help with how to properly set the authorities would be greatly appreciated. One thing to note is my token is converted from oauth2 to jwt (due to stateless session).

The related code from my app is below for reference:
auth.ts

import { configureAuth } from '@vaadin/hilla-react-auth';
import { AuthInfoService } from './generated/endpoints';

const auth = configureAuth(AuthInfoService.getAuthInfo);

export const useAuth = auth.useAuth;
export const AuthProvider = auth.AuthProvider;

AuthInfoService.java

package ai.goacquire.frontend.security;

import ai.goacquire.shared.dto.AuthInfoDTO;
import ai.goacquire.shared.mapper.AuthInfoMapper;
import ai.goacquire.shared.repository.UserRepository;
import com.vaadin.hilla.BrowserCallable;
import jakarta.annotation.security.PermitAll;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;

import java.time.Instant;
import java.util.Optional;

@BrowserCallable
public class AuthInfoService {
    
    private final UserRepository userRepository;
    private final AuthInfoMapper authInfoMapper;

    public AuthInfoService(UserRepository userRepository, AuthInfoMapper authInfoMapper) {
        this.userRepository = userRepository;
        this.authInfoMapper = authInfoMapper;
    }

    @PermitAll
    public Optional<AuthInfoDTO> getAuthInfo() {
        Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        
        if (principal instanceof Jwt) {
            String username = ((Jwt) principal).getSubject();
            if (((Jwt) principal).getExpiresAt().isAfter(Instant.now())) {
                AuthInfoDTO authInfo = authInfoMapper.toDto(userRepository.findByEmail(username).orElse(null));
                return Optional.of(authInfo);
            }
        }
        // Anonymous or no authentication.
        return Optional.empty();
    }
}

SecurityConfig.java

package ai.goacquire.frontend.config;

import ai.goacquire.frontend.security.OAuth2LoginSuccessHandler;
import com.vaadin.flow.spring.security.VaadinWebSecurity;
import com.vaadin.hilla.route.RouteUtil;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.jose.jws.JwsAlgorithms;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;

@EnableWebSecurity
@Configuration
@EnableMethodSecurity
public class SecurityConfig extends VaadinWebSecurity {
    protected final Log logger = LogFactory.getLog(this.getClass());
    private final RouteUtil routeUtil;
    private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;
    @Value("${auth.secret}")
    private String authSecret;

    public SecurityConfig(RouteUtil routeUtil, OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler) {
        this.routeUtil = routeUtil;
        this.oAuth2LoginSuccessHandler = oAuth2LoginSuccessHandler;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // First set up all the OAuth and security related paths
        http.authorizeHttpRequests(registry -> registry
                .requestMatchers(routeUtil::isRouteAllowed).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/login")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/login/oauth2/authorization/microsoft")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/login/oauth2/authorization/google")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/access-denied")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/error")).permitAll()
        )
        // Configure OAuth2 login
        .oauth2Login(oauth2 -> oauth2
                .loginPage("/login")
                .successHandler(oAuth2LoginSuccessHandler)
        );

        // Default config to allow Vaadin paths
        super.configure(http);
        
        http.sessionManagement(httpSecuritySessionManagementConfigurer -> httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED));

        setLoginView(http, "/login");

        // Enable stateless authentication
        setStatelessAuthentication(http,
                new SecretKeySpec(Base64.getDecoder().decode(authSecret), JwsAlgorithms.HS256)
                ,"ai.goacquire.frontend"
                , 43200);
    }
}

And finally OAuth2LoginSuccessHandler

package ai.goacquire.frontend.security;

import ai.goacquire.shared.entity.User;
import ai.goacquire.shared.mapper.AuthInfoMapper;
import ai.goacquire.shared.service.UserLoginService;
import ai.goacquire.shared.service.UserLoginService.UserLoginException;
import jakarta.servlet.http.Cookie;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;

@Component
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    private static final Logger logger = LoggerFactory.getLogger(OAuth2LoginSuccessHandler.class);
    private final UserLoginService userLoginService;


    public OAuth2LoginSuccessHandler(UserLoginService userLoginService, AuthInfoMapper authInfoMapper) {
        this.userLoginService = userLoginService;
        setDefaultTargetUrl("/");
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException {
        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();

        // ENHANCED DEBUGGING: Log all available attributes
        logger.info("OAuth authentication success. Attributes received: {}", oAuth2User.getAttributes());

        try {
            String provider = determineProvider(request, oAuth2User);
            logger.info("Detected provider: {}", provider);

            String providerId = getAttributeValue(oAuth2User, "sub", "oid");
            String email = getAttributeValue(oAuth2User, "email", "preferred_username", "upn", "mail");
            String firstName = getAttributeValue(oAuth2User, "given_name", "givenname", "givenName");
            String lastName = getAttributeValue(oAuth2User, "family_name", "surname", "familyname", "lastname");

            // ENHANCED DEBUGGING: Log extracted user information
            logger.info("Extracted OAuth user info - Provider ID: {}, Email: {}, First Name: {}, Last Name: {}",
                    providerId, email, firstName, lastName);

            validateRequiredFields(email, providerId);

            logger.info("Attempting login for user email: {}, provider: {}", email, provider);

            try {
                User user = userLoginService.login(email, firstName, lastName, provider, providerId);
                // should I do something here to update the authorities in the authentication object?
                logger.info("Successful login for user: {}", email);
                for (Cookie cookie : request.getCookies())
                {
                    if (cookie.getName().equals("REDIRECT_URI")) {
                        byte[] decodedBytes = Base64.getDecoder().decode(cookie.getValue());
                        String redirectUri = new String(decodedBytes, "UTF-8");
                        setDefaultTargetUrl(redirectUri);
                        break;
                    }
                }
                super.onAuthenticationSuccess(request, response, authentication);
            } catch (UserLoginException e) {
                // ENHANCED DEBUGGING: Log detailed exception information
                logger.warn("Login failed for user: {} - Error type: {} - Message: {}",
                        email, e.getErrorType(), e.getMessage());

                switch (e.getErrorType()) {
                    case NOT_REGISTERED:
                        logger.info("Redirecting to access-denied/not-registered");
                        response.sendRedirect("/access-denied/not-registered");
                        break;
                    case INACTIVE_USER:
                        logger.info("Redirecting to access-denied/inactive");
                        response.sendRedirect("/access-denied/inactive");
                        break;
                    case SYSTEM_ERROR:
                    default:
                        logger.error("System error during login: {}", e.getMessage(), e);
                        logger.info("Redirecting to access-denied/default");
                        response.sendRedirect("/access-denied/default");
                        break;
                }
            }
        } catch (IllegalStateException e) {
            logger.error("Provider determination or validation failed: {}", e.getMessage(), e);
            logger.info("Redirecting to login with error parameter");
            response.sendRedirect("/login?error");
        } catch (Exception e) {
            logger.error("Unexpected error during authentication: ", e);
            logger.info("Redirecting to login with error parameter");
            response.sendRedirect("/login?error");
        }
    }

    // Existing helper methods remain unchanged
    private String getAttributeValue(OAuth2User oAuth2User, String... possibleKeys) {
        Map<String, Object> attributes = oAuth2User.getAttributes();
        for (String key : possibleKeys) {
            Object value = attributes.get(key);
            if (value != null) {
                return value.toString();
            }
        }
        return null;
    }

    private String determineProvider(HttpServletRequest request, OAuth2User oAuth2User) {
        // ENHANCED DEBUGGING: Log the request URI and available attributes for provider detection
        logger.info("Determining provider from request URI: {}", request.getRequestURI());
        logger.info("OAuth attributes for provider detection: {}", oAuth2User.getAttributes());

        // Check URL first
        if (request.getRequestURI().contains("google")) {
            return "google";
        } else if (request.getRequestURI().contains("microsoft")) {
            return "microsoft";
        }

        // Fallback to checking attributes
        Map<String, Object> attributes = oAuth2User.getAttributes();
        if (attributes.containsKey("iss")) {
            String issuer = attributes.get("iss").toString();
            logger.info("Found issuer attribute: {}", issuer);
            if (issuer.contains("google")) {
                return "google";
            } else if (issuer.contains("microsoft") || issuer.contains("live.com")) {
                return "microsoft";
            }
        }

        // ENHANCED DEBUGGING: Log detailed information before throwing exception
        logger.error("Unable to determine OAuth2 provider. Request URI: {}, Available attributes: {}",
                request.getRequestURI(), attributes.keySet());
        throw new IllegalStateException("Unable to determine OAuth2 provider");
    }

    private void validateRequiredFields(String email, String providerId) {
        if (email == null || email.trim().isEmpty()) {
            logger.error("Email is required but was not provided by OAuth2 provider");
            throw new IllegalStateException("Email is required but was not provided by OAuth2 provider");
        }
        if (providerId == null || providerId.trim().isEmpty()) {
            logger.error("Provider ID is required but was not provided by OAuth2 provider");
            throw new IllegalStateException("Provider ID is required but was not provided by OAuth2 provider");
        }
    }
}

Figured out a fix… Had to use an AuthoritiesMapper rather than attempting to modify the Authentication object inside the LoginSuccessHandler.

package ai.goacquire.frontend.security;

import ai.goacquire.shared.entity.User;
import ai.goacquire.shared.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.user.OAuth2UserAuthority;
import org.springframework.stereotype.Component;

import java.util.*;
import java.util.stream.Collectors;

@Component
public class CustomUserAuthoritiesMapper implements GrantedAuthoritiesMapper {
    private static final Logger logger = LoggerFactory.getLogger(CustomUserAuthoritiesMapper.class);

    private final UserRepository userRepository;

    public CustomUserAuthoritiesMapper(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public Collection<? extends GrantedAuthority> mapAuthorities(Collection<? extends GrantedAuthority> authorities) {
        logger.info("Mapping authorities. Original authorities: {}", authorities);
        
        // Find the OAuth2UserAuthority which contains user attributes
        OAuth2UserAuthority oauth2UserAuthority = authorities.stream()
                .filter(OAuth2UserAuthority.class::isInstance)
                .map(OAuth2UserAuthority.class::cast)
                .findFirst()
                .orElse(null);

        if (oauth2UserAuthority == null) {
            logger.warn("No OAuth2UserAuthority found, returning original authorities");
            return authorities;
        }

        Map<String, Object> attributes = oauth2UserAuthority.getAttributes();
        logger.info("OAuth2 user attributes: {}", attributes);

        // Extract user information from OAuth2 attributes
        String email = getAttributeValue(attributes, "email", "preferred_username", "upn", "mail");

        try {
            User user = userRepository.findByEmail(email).orElse(null);
            logger.info("Successfully retrieved user: {}. User roles: {}",
                    email, user.getRoles().stream().map(role -> role.getName()).collect(Collectors.toList()));

            // Create authorities from database roles
            List<GrantedAuthority> newAuthorities = new ArrayList<>();

            // Keep original OAuth2 scopes/authorities if needed
            newAuthorities.addAll(authorities);

            // Add database-backed role authorities
            List<GrantedAuthority> roleAuthorities = user.getRoles().stream()
                    .map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName().toUpperCase()))
                    .collect(Collectors.toList());

            newAuthorities.addAll(roleAuthorities);

            logger.info("Final mapped authorities: {}", newAuthorities);
            return newAuthorities;

        } catch (Exception e) {
            logger.error("Error during authority mapping for user: {} error: {}", email, e.getMessage());
            return authorities;
        }
    }

    private String getAttributeValue(Map<String, Object> attributes, String... possibleKeys) {
        for (String key : possibleKeys) {
            Object value = attributes.get(key);
            if (value != null) {
                return value.toString();
            }
        }
        return null;
    }
}

and my SecurityConfig:

package ai.goacquire.frontend.config;

import ai.goacquire.frontend.security.OAuth2LoginSuccessHandler;
import ai.goacquire.frontend.security.CustomUserAuthoritiesMapper;
import com.vaadin.flow.spring.security.VaadinWebSecurity;
import com.vaadin.hilla.route.RouteUtil;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.jose.jws.JwsAlgorithms;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;

@EnableWebSecurity
@Configuration
@EnableMethodSecurity
public class SecurityConfig extends VaadinWebSecurity {
    protected final Log logger = LogFactory.getLog(this.getClass());
    private final RouteUtil routeUtil;
    private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;
    private final CustomUserAuthoritiesMapper customUserAuthoritiesMapper;
    @Value("${auth.secret}")
    private String authSecret;

    public SecurityConfig(RouteUtil routeUtil, 
                         OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler,
                         CustomUserAuthoritiesMapper customUserAuthoritiesMapper) {
        this.routeUtil = routeUtil;
        this.oAuth2LoginSuccessHandler = oAuth2LoginSuccessHandler;
        this.customUserAuthoritiesMapper = customUserAuthoritiesMapper;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // First set up all the OAuth and security related paths
        http.authorizeHttpRequests(registry -> registry
                .requestMatchers(routeUtil::isRouteAllowed).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/login")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/login/oauth2/authorization/microsoft")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/login/oauth2/authorization/google")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/access-denied")).permitAll()
                .requestMatchers(new AntPathRequestMatcher("/error")).permitAll()
        )
        // Configure OAuth2 login with custom user authorities mapper
        .oauth2Login(oauth2 -> oauth2
                .loginPage("/login")
                .successHandler(oAuth2LoginSuccessHandler)
                .userInfoEndpoint(userInfo -> userInfo
                    .userAuthoritiesMapper(customUserAuthoritiesMapper)
                )
        );

        // Default config to allow Vaadin paths
        super.configure(http);
        
        http.sessionManagement(httpSecuritySessionManagementConfigurer -> 
            httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        setLoginView(http, "/login");

        // Enable stateless authentication
        setStatelessAuthentication(http,
                new SecretKeySpec(Base64.getDecoder().decode(authSecret), JwsAlgorithms.HS256)
                ,"ai.goacquire.frontend"
                , 43200);
    }
}

I had to make sure to use the ROLE_ prefix when adding them as authorities, otherwise they weren’t being passed through.

Makes sense after digging through all the Spring / Vaadin code involved. Would be much better if the docs said something about this situation though.