diff --git a/vaadin-spring/src/main/java/com/vaadin/flow/spring/security/KeycloakOidcUserMapper.java b/vaadin-spring/src/main/java/com/vaadin/flow/spring/security/KeycloakOidcUserMapper.java new file mode 100644 index 00000000000..a5bfcb5de61 --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/security/KeycloakOidcUserMapper.java @@ -0,0 +1,258 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.flow.spring.security; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserSource; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtDecoderFactory; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.util.StringUtils; + +/** + * Maps Keycloak realm and client roles to Spring Security granted authorities. + *

+ * Keycloak puts the roles of a user into the access token rather than into the + * ID token, so they are not part of the {@link OidcUser} that + * {@link OidcUserService} builds by default. This converter decodes the access + * token and adds + *

+ * Realm and client roles both become role authorities, using the role prefix of + * the application ({@code ROLE_} unless a + * {@link org.springframework.security.config.core.GrantedAuthorityDefaults} + * bean says otherwise), so that {@code @RolesAllowed("admin")} and + * {@code hasRole("admin")} match a Keycloak role named {@code admin}. Roles + * that {@code resource_access} grants for other clients are ignored. + *

+ * An access token that is not a JWT, or that this application is not allowed to + * decode, is not an error: the user is mapped without any role authorities, as + * the default {@link OidcUserService} would do. + *

+ * The recommended way to use this converter is + * {@link VaadinSecurityConfigurer#keycloakRoleMapping()}, which installs it for + * a single security filter chain. Applications that build their own + * {@link OidcUserService} can install it directly: + * + *

+ * 
+ * var oidcUserService = new OidcUserService();
+ * oidcUserService.setOidcUserConverter(new KeycloakOidcUserMapper());
+ * 
+ * 
+ * + * @author Vaadin Ltd + * @since 25.4 + */ +public class KeycloakOidcUserMapper + implements Converter { + + private static final Logger LOGGER = LoggerFactory + .getLogger(KeycloakOidcUserMapper.class); + + private static final String REALM_ACCESS_CLAIM = "realm_access"; + + private static final String RESOURCE_ACCESS_CLAIM = "resource_access"; + + private static final String ROLES_CLAIM = "roles"; + + private static final String DEFAULT_ROLE_PREFIX = "ROLE_"; + + private static final String SCOPE_PREFIX = "SCOPE_"; + + private final Supplier rolePrefix; + + private final JwtDecoderFactory decoderFactory; + + /** + * Decoders by registration id, empty for a registration that has no + * decoder, so that one is not built again on every login. + */ + private final Map> decoders = new ConcurrentHashMap<>(); + + /** + * Creates a mapper that prefixes roles with {@code ROLE_}. + */ + public KeycloakOidcUserMapper() { + this(() -> null, KeycloakOidcUserMapper::createDecoder); + } + + /** + * Creates a mapper that prefixes roles with the given prefix. + * + * @param rolePrefix + * the prefix to add to a Keycloak role name, or {@code null} to + * use {@code ROLE_} + */ + public KeycloakOidcUserMapper(String rolePrefix) { + this(() -> rolePrefix, KeycloakOidcUserMapper::createDecoder); + } + + /** + * Creates a mapper that looks up the role prefix when it maps a user, for a + * caller that only knows the prefix once the security filter chain has been + * configured. + * + * @param rolePrefix + * supplies the prefix to add to a Keycloak role name, may supply + * {@code null} to use {@code ROLE_} + */ + KeycloakOidcUserMapper(Supplier rolePrefix) { + this(rolePrefix, KeycloakOidcUserMapper::createDecoder); + } + + KeycloakOidcUserMapper(Supplier rolePrefix, + JwtDecoderFactory decoderFactory) { + this.rolePrefix = rolePrefix != null ? rolePrefix : () -> null; + this.decoderFactory = decoderFactory; + } + + @Override + public OidcUser convert(OidcUserSource userSource) { + var userRequest = userSource.getUserRequest(); + var userInfo = userSource.getUserInfo(); + var idToken = userRequest.getIdToken(); + var accessToken = userRequest.getAccessToken(); + var clientRegistration = userRequest.getClientRegistration(); + var authorities = new LinkedHashSet(); + accessToken.getScopes().stream() + .map(scope -> new SimpleGrantedAuthority(SCOPE_PREFIX + scope)) + .forEach(authorities::add); + decodeAccessToken(clientRegistration, accessToken.getTokenValue()) + .ifPresent(jwt -> collectRoles(jwt, + clientRegistration.getClientId(), authorities)); + var userNameAttributeName = clientRegistration.getProviderDetails() + .getUserInfoEndpoint().getUserNameAttributeName(); + if (StringUtils.hasText(userNameAttributeName)) { + authorities.add(new OidcUserAuthority(idToken, userInfo, + userNameAttributeName)); + return new DefaultOidcUser(authorities, idToken, userInfo, + userNameAttributeName); + } + authorities.add(new OidcUserAuthority(idToken, userInfo)); + return new DefaultOidcUser(authorities, idToken, userInfo); + } + + private void collectRoles(Jwt accessToken, String clientId, + Set authorities) { + var claims = accessToken.getClaims(); + var resourceAccess = asMap(claims.get(RESOURCE_ACCESS_CLAIM)); + Stream.of(asMap(claims.get(REALM_ACCESS_CLAIM)), + asMap(resourceAccess.get(clientId))) + .flatMap(access -> extractRoles(access).stream()) + .map(this::toRoleAuthority).forEach(authorities::add); + } + + /** + * Decodes the access token with the decoder of the given client + * registration, which is created on first use and then reused. + */ + private Optional decodeAccessToken(ClientRegistration registration, + String tokenValue) { + var decoder = decoders.computeIfAbsent(registration.getRegistrationId(), + id -> Optional.ofNullable( + decoderFactory.createDecoder(registration))); + if (decoder.isEmpty()) { + LOGGER.debug( + "Client registration '{}' has no JWK set URI, so its access " + + "token cannot be decoded and no Keycloak roles " + + "are mapped for it", + registration.getRegistrationId()); + return Optional.empty(); + } + try { + return Optional.of(decoder.get().decode(tokenValue)); + } catch (JwtException e) { + LOGGER.debug( + "The access token of client registration '{}' could not be " + + "decoded as a JWT, so no Keycloak roles are " + + "mapped for it", + registration.getRegistrationId(), e); + return Optional.empty(); + } + } + + private GrantedAuthority toRoleAuthority(String role) { + return new SimpleGrantedAuthority(rolePrefix() + role); + } + + /** + * Returns the role prefix in use, resolved on every call so that a prefix + * that is only known once the security filter chain is fully configured is + * picked up. + */ + String rolePrefix() { + var prefix = rolePrefix.get(); + return prefix != null ? prefix : DEFAULT_ROLE_PREFIX; + } + + @SuppressWarnings("unchecked") + private static List extractRoles(Map access) { + var roles = access.get(ROLES_CLAIM); + return roles instanceof List ? (List) roles : List.of(); + } + + @SuppressWarnings("unchecked") + private static Map asMap(Object value) { + return value instanceof Map map ? (Map) map + : Collections.emptyMap(); + } + + /** + * Creates a decoder for the access tokens of the given client registration, + * or {@code null} when the registration has no JWK set URI to verify them + * against. + */ + private static JwtDecoder createDecoder(ClientRegistration registration) { + var providerDetails = registration.getProviderDetails(); + var jwkSetUri = providerDetails.getJwkSetUri(); + if (!StringUtils.hasText(jwkSetUri)) { + return null; + } + var issuerUri = providerDetails.getIssuerUri(); + var decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build(); + decoder.setJwtValidator(StringUtils.hasText(issuerUri) + ? JwtValidators.createDefaultWithIssuer(issuerUri) + : JwtValidators.createDefault()); + return decoder; + } +} diff --git a/vaadin-spring/src/main/java/com/vaadin/flow/spring/security/KeycloakRoleMapping.java b/vaadin-spring/src/main/java/com/vaadin/flow/spring/security/KeycloakRoleMapping.java new file mode 100644 index 00000000000..6291568de2f --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/security/KeycloakRoleMapping.java @@ -0,0 +1,67 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.flow.spring.security; + +import java.util.function.Supplier; + +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2LoginConfigurer; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; + +/** + * Makes an OAuth2 login use an {@link OidcUserService} that maps Keycloak roles + * with {@link KeycloakOidcUserMapper}. + *

+ * This lives apart from {@link VaadinSecurityConfigurer} on purpose. The + * {@code spring-security-oauth2-client} dependency that {@link OidcUserService} + * comes from is optional, and passing an {@code OidcUserService} to a parameter + * of type {@code OAuth2UserService} makes the verifier load the latter to check + * assignability. Doing that from {@code VaadinSecurityConfigurer} would break + * every application that configures Vaadin security without the dependency, + * because a class is verified as a whole when it is loaded. Keeping it here + * means the class is only loaded by an application that asks for Keycloak role + * mapping, which has the dependency anyway. + * + * @see VaadinSecurityConfigurer#keycloakRoleMapping() + */ +final class KeycloakRoleMapping { + + private KeycloakRoleMapping() { + // Only static utility methods + } + + /** + * Sets an {@link OidcUserService} that maps Keycloak roles on the given + * OAuth2 login, and shares it so that it can be inspected or replaced. + * + * @param loginConfigurer + * the OAuth2 login configurer to customize + * @param http + * the security builder to share the service with + * @param rolePrefix + * supplies the role prefix to use, resolved when a user is + * mapped + */ + static void apply(OAuth2LoginConfigurer loginConfigurer, + HttpSecurity http, Supplier rolePrefix) { + var oidcUserService = new OidcUserService(); + oidcUserService + .setOidcUserConverter(new KeycloakOidcUserMapper(rolePrefix)); + http.setSharedObject(OidcUserService.class, oidcUserService); + loginConfigurer.userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint + .oidcUserService(oidcUserService)); + } +} diff --git a/vaadin-spring/src/main/java/com/vaadin/flow/spring/security/VaadinSecurityConfigurer.java b/vaadin-spring/src/main/java/com/vaadin/flow/spring/security/VaadinSecurityConfigurer.java index 00c0a6a6d71..d652a09b09f 100644 --- a/vaadin-spring/src/main/java/com/vaadin/flow/spring/security/VaadinSecurityConfigurer.java +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/security/VaadinSecurityConfigurer.java @@ -133,6 +133,8 @@ *

  • {@link VaadinDefaultRequestCache}
  • *
  • {@link VaadinSavedRequestAwareAuthenticationSuccessHandler}
  • *
  • {@link ClientRegistrationRepository}
  • + *
  • {@code OidcUserService}, when Keycloak role mapping is enabled with + * {@link #keycloakRoleMapping()}
  • * * * @since 24.8 @@ -159,6 +161,8 @@ public final class VaadinSecurityConfigurer private String postLogoutRedirectUri; + private boolean keycloakRoleMapping = false; + private boolean enableCsrfConfiguration = true; private boolean enableLogoutConfiguration = true; @@ -316,6 +320,32 @@ public VaadinSecurityConfigurer oauth2LoginPage(String oauth2LoginPage, return this; } + /** + * Enables mapping of Keycloak realm and client roles to Spring Security + * granted authorities (disabled by default). + *

    + * Keycloak puts the roles of a user into the access token, so they are not + * part of the authenticated user by default. This opts in to decoding the + * access token and mapping its roles, which makes + * {@code @RolesAllowed("admin")} and {@code hasRole("admin")} match a + * Keycloak role named {@code admin}. See {@link KeycloakOidcUserMapper} for + * what exactly is mapped. + *

    + * Works only together with {@link #oauth2LoginPage(String)} and its + * overloads, and sets the {@code OidcUserService} that this security filter + * chain uses to load the authenticated user. An application that has its + * own {@code OidcUserService} should leave this off and install the mapper + * on that service instead, as {@link KeycloakOidcUserMapper} shows. + * + * @return the current configurer instance for method chaining + * @see KeycloakOidcUserMapper + * @since 25.4 + */ + public VaadinSecurityConfigurer keycloakRoleMapping() { + this.keycloakRoleMapping = true; + return this; + } + /** * Sets the default success URL after authentication. Redirected only when * no protected page was previously accessed. Works only together with @@ -553,7 +583,22 @@ public void init(HttpSecurity http) { http.oauth2Login(configurer -> { configurer.loginPage(oauth2LoginPage).permitAll(); configurer.successHandler(getAuthenticationSuccessHandler()); + if (keycloakRoleMapping) { + // The role prefix holder is only populated with the prefix + // of the filter chain in configure(), which runs after + // this, so the prefix is resolved when a user is mapped + var rolePrefixHolder = getVaadinRolePrefixHolder(); + KeycloakRoleMapping.apply(configurer, getBuilder(), + rolePrefixHolder != null + ? rolePrefixHolder::getRolePrefix + : () -> null); + } }); + } else if (keycloakRoleMapping) { + LOGGER.warn( + "Keycloak role mapping is enabled but no OAuth2 login page " + + "is configured, so it has no effect. Configure " + + "one with VaadinSecurityConfigurer.oauth2LoginPage()."); } if (enableCsrfConfiguration) { http.csrf(this::customizeCsrf); diff --git a/vaadin-spring/src/test/java/com/vaadin/flow/spring/SpringClassesSerializableTest.java b/vaadin-spring/src/test/java/com/vaadin/flow/spring/SpringClassesSerializableTest.java index d3681b4ba31..9c890493e89 100644 --- a/vaadin-spring/src/test/java/com/vaadin/flow/spring/SpringClassesSerializableTest.java +++ b/vaadin-spring/src/test/java/com/vaadin/flow/spring/SpringClassesSerializableTest.java @@ -105,6 +105,10 @@ protected Stream getExcludedPatterns() { "com\\.vaadin\\.flow\\.spring\\.scopes\\.AbstractScope", "com\\.vaadin\\.flow\\.spring\\.scopes\\.VaadinUIScope", "com\\.vaadin\\.flow\\.spring\\.security\\.AuthenticationContext", + // Part of the security filter chain, not of session state, + // like the OidcUserService that holds it + "com\\.vaadin\\.flow\\.spring\\.security\\.KeycloakOidcUserMapper", + "com\\.vaadin\\.flow\\.spring\\.security\\.KeycloakRoleMapping", "com\\.vaadin\\.flow\\.spring\\.security\\.NavigationAccessControlConfigurer", "com\\.vaadin\\.flow\\.spring\\.security\\.VaadinAwareSecurityContextHolderStrategy", "com\\.vaadin\\.flow\\.spring\\.security\\.VaadinAwareSecurityContextHolderStrategyConfiguration", diff --git a/vaadin-spring/src/test/java/com/vaadin/flow/spring/security/KeycloakOidcUserMapperTest.java b/vaadin-spring/src/test/java/com/vaadin/flow/spring/security/KeycloakOidcUserMapperTest.java new file mode 100644 index 00000000000..ced99d05c59 --- /dev/null +++ b/vaadin-spring/src/test/java/com/vaadin/flow/spring/security/KeycloakOidcUserMapperTest.java @@ -0,0 +1,232 @@ +/* + * Copyright 2000-2026 Vaadin Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package com.vaadin.flow.spring.security; + +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserSource; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistration.ProviderDetails; +import org.springframework.security.oauth2.client.registration.ClientRegistration.ProviderDetails.UserInfoEndpoint; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority; +import org.springframework.security.oauth2.jwt.BadJwtException; +import org.springframework.security.oauth2.jwt.Jwt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +/** + * Specification of the opt-in mapping of Keycloak realm and client roles to + * Spring Security granted authorities. + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class KeycloakOidcUserMapperTest { + + private static final String CLIENT_ID = "test-client"; + + private static final String ISSUER_URI = "http://localhost:8080/realms/test"; + + private static final String JWK_SET_URI = ISSUER_URI + + "/protocol/openid-connect/certs"; + + @Mock + private OidcUserSource userSource; + + @Mock + private OidcUserRequest userRequest; + + @Mock + private OidcUserInfo userInfo; + + @Mock + private OidcIdToken idToken; + + @Mock + private ClientRegistration clientRegistration; + + @Mock + private ProviderDetails providerDetails; + + @Mock + private UserInfoEndpoint userInfoEndpoint; + + @Mock + private OAuth2AccessToken accessToken; + + private final Map accessTokenClaims = new HashMap<>(); + + private final AtomicInteger decoderFactoryCalls = new AtomicInteger(); + + private KeycloakOidcUserMapper mapper; + + @BeforeEach + void setup() { + mapper = new KeycloakOidcUserMapper(null, registration -> { + decoderFactoryCalls.incrementAndGet(); + return this::decode; + }); + + when(userSource.getUserRequest()).thenReturn(userRequest); + when(userSource.getUserInfo()).thenReturn(userInfo); + when(userRequest.getClientRegistration()) + .thenReturn(clientRegistration); + when(userRequest.getAccessToken()).thenReturn(accessToken); + when(userRequest.getIdToken()).thenReturn(idToken); + when(clientRegistration.getRegistrationId()).thenReturn("keycloak"); + when(clientRegistration.getClientId()).thenReturn(CLIENT_ID); + when(clientRegistration.getProviderDetails()) + .thenReturn(providerDetails); + when(providerDetails.getUserInfoEndpoint()) + .thenReturn(userInfoEndpoint); + when(accessToken.getTokenValue()).thenReturn("token"); + when(accessToken.getScopes()).thenReturn(Set.of("openid", "profile")); + when(idToken.getClaims()) + .thenReturn(Map.of("sub", "user-123", "iss", ISSUER_URI)); + } + + @Test + void convert_realmAndClientRolesAndScopesMapped() { + accessTokenClaims.put("realm_access", + Map.of("roles", List.of("admin", "user"))); + accessTokenClaims.put("resource_access", + Map.of(CLIENT_ID, Map.of("roles", List.of("manage-account")))); + + var authorities = authorities(); + + assertThat(authorities).contains("ROLE_admin", "ROLE_user", + "ROLE_manage-account", "SCOPE_openid", "SCOPE_profile"); + assertThat(mapper.convert(userSource).getAuthorities()) + .hasAtLeastOneElementOfType(OidcUserAuthority.class); + } + + @Test + void convert_otherClientRolesAndMissingClaimsIgnored() { + accessTokenClaims.put("resource_access", + Map.of("other-client", Map.of("roles", List.of("other-role")))); + + assertThat(authorities()).noneMatch(a -> a.startsWith("ROLE_")); + } + + @Test + void convert_customRolePrefixAppliedToRolesOnly() { + mapper = new KeycloakOidcUserMapper(() -> "AUTHORITY_", + registration -> this::decode); + accessTokenClaims.put("realm_access", + Map.of("roles", List.of("admin"))); + + assertThat(authorities()).contains("AUTHORITY_admin", "SCOPE_openid") + .doesNotContain("ROLE_admin"); + } + + @Test + void convert_accessTokenNotAJwt_mappedWithoutRoles() { + mapper = new KeycloakOidcUserMapper(null, registration -> token -> { + throw new BadJwtException("not a JWT"); + }); + + var authorities = authorities(); + + assertThat(authorities).noneMatch(a -> a.startsWith("ROLE_")); + assertThat(authorities).contains("SCOPE_openid"); + } + + @Test + void convert_userNameAttributeUsedAsName_userInfoRetained() { + when(userInfoEndpoint.getUserNameAttributeName()) + .thenReturn("preferred_username"); + when(userInfo.getClaims()).thenReturn(Map.of("preferred_username", + "john", "email", "john@example.com")); + + var user = mapper.convert(userSource); + + // The name attribute is only in the userinfo response, so dropping it + // would both lose claims and fail the login + assertThat(user.getName()).isEqualTo("john"); + assertThat(user.getUserInfo()).isSameAs(userInfo); + assertThat(user.getClaims()).containsEntry("email", "john@example.com"); + } + + @Test + void convert_registrationWithoutJwkSetUri_mappedWithoutRoles() { + // There is nothing to verify the access token against, so it is left + // alone rather than failing the login + mapper = new KeycloakOidcUserMapper(); + + var authorities = authorities(); + + assertThat(authorities).noneMatch(a -> a.startsWith("ROLE_")); + assertThat(authorities).contains("SCOPE_openid"); + } + + @Test + void convert_registrationWithoutIssuerUri_mappedWithoutRoles() { + // A registration set up with explicit endpoints has no issuer URI, and + // building the default validator with a null issuer must not fail the + // login + when(providerDetails.getJwkSetUri()).thenReturn(JWK_SET_URI); + mapper = new KeycloakOidcUserMapper(); + + var authorities = authorities(); + + assertThat(authorities).noneMatch(a -> a.startsWith("ROLE_")); + assertThat(authorities).contains("SCOPE_openid"); + } + + @Test + void convert_decoderCreatedOncePerClientRegistration() { + mapper.convert(userSource); + mapper.convert(userSource); + + assertThat(decoderFactoryCalls).hasValue(1); + } + + private List authorities() { + return mapper.convert(userSource).getAuthorities().stream() + .map(GrantedAuthority::getAuthority).toList(); + } + + private Jwt decode(String tokenValue) { + var issuedAt = Instant.parse("2026-01-01T00:00:00Z"); + // @formatter:off + return Jwt.withTokenValue(tokenValue) + .header("alg", "RS256") + .issuer(ISSUER_URI) + .issuedAt(issuedAt) + .expiresAt(issuedAt.plusSeconds(60)) + .subject("user-123") + .claims(claims -> claims.putAll(accessTokenClaims)) + .build(); + // @formatter:on + } +} diff --git a/vaadin-spring/src/test/java/com/vaadin/flow/spring/security/VaadinSecurityConfigurerTest.java b/vaadin-spring/src/test/java/com/vaadin/flow/spring/security/VaadinSecurityConfigurerTest.java index acea2e50ead..c8058950ccc 100644 --- a/vaadin-spring/src/test/java/com/vaadin/flow/spring/security/VaadinSecurityConfigurerTest.java +++ b/vaadin-spring/src/test/java/com/vaadin/flow/spring/security/VaadinSecurityConfigurerTest.java @@ -20,6 +20,7 @@ import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; import java.lang.reflect.Method; import java.util.List; import java.util.Map; @@ -59,6 +60,7 @@ import org.springframework.security.config.annotation.web.configurers.RequestCacheConfigurer; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter; import org.springframework.security.web.access.ExceptionTranslationFilter; @@ -71,6 +73,7 @@ import org.springframework.security.web.csrf.CsrfFilter; import org.springframework.security.web.savedrequest.RequestCacheAwareFilter; import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -186,6 +189,51 @@ void oauth2LoginPage_chainHasAuthenticationFilter() { OAuth2LoginAuthenticationFilter.class); } + @Test + void keycloakRoleMapping_withOAuth2LoginPage_oidcUserServiceMapsRoles() + throws Exception { + http.with(configurer, + c -> c.oauth2LoginPage("/oauth2/authorization/keycloak") + .keycloakRoleMapping()) + .build(); + + var oidcUserService = http.getSharedObject(OidcUserService.class); + + assertThat(oidcUserService).isNotNull(); + assertThat(getOidcUserConverter(oidcUserService)) + .isInstanceOf(KeycloakOidcUserMapper.class); + } + + @Test + void keycloakRoleMapping_withoutOAuth2LoginPage_notConfigured() { + http.with(configurer, VaadinSecurityConfigurer::keycloakRoleMapping) + .build(); + + assertNull(http.getSharedObject(OidcUserService.class)); + } + + @Test + void keycloakRoleMapping_rolePrefixOfChain_isUsedForRoles() + throws Exception { + var rolePrefixHolder = new VaadinRolePrefixHolder(null); + http.setSharedObject(VaadinRolePrefixHolder.class, rolePrefixHolder); + + http.with(configurer, + c -> c.oauth2LoginPage("/oauth2/authorization/keycloak") + .keycloakRoleMapping()) + .build(); + // The prefix of the filter chain is only known to the holder after the + // chain has been configured, so the mapper must pick it up afterwards + var securityContextFilter = new SecurityContextHolderAwareRequestFilter(); + securityContextFilter.setRolePrefix("AUTHORITY_"); + rolePrefixHolder.resetRolePrefix(securityContextFilter); + + var mapper = (KeycloakOidcUserMapper) getOidcUserConverter( + http.getSharedObject(OidcUserService.class)); + + assertThat(mapper.rolePrefix()).isEqualTo("AUTHORITY_"); + } + @Test void logoutSuccessHandler_handlerIsConfigured( @Mock LogoutSuccessHandler handler) { @@ -486,6 +534,17 @@ void defaultSuccessUrl_notSet_usesRootPath() throws Exception { assertThat(isAlwaysUseDefaultTargetUrl(handler)).isFalse(); } + @Test + void withoutOAuth2ClientOnClasspath_configurerStillLinks() { + // spring-security-oauth2-client is an optional dependency, and a class + // is verified as a whole when it is loaded, so a reference to one of + // its types here would break every application that does not have it + assertThatCode( + () -> Class.forName(VaadinSecurityConfigurer.class.getName(), + true, new OAuth2ClientHidingClassLoader())) + .doesNotThrowAnyException(); + } + @ParameterizedTest @ValueSource(strings = { "style", "script", "image", "font" }) void anonymousSubResourceRequest_respondsWithUnauthorized(String fetchDest) @@ -528,7 +587,66 @@ private MockHttpServletResponse sendAnonymousGetRequest(String path, return mockResponse; } + /** + * Loads the Vaadin security classes itself, so that they are verified + * against a classpath without {@code spring-security-oauth2-client}. + */ + private static class OAuth2ClientHidingClassLoader extends ClassLoader { + + private static final String HIDDEN_PACKAGE = "org.springframework.security.oauth2.client."; + + private static final String RELOADED_PACKAGE = "com.vaadin.flow.spring.security."; + + OAuth2ClientHidingClassLoader() { + super(VaadinSecurityConfigurer.class.getClassLoader()); + } + + @Override + protected Class loadClass(String name, boolean resolve) + throws ClassNotFoundException { + if (name.startsWith(HIDDEN_PACKAGE)) { + throw new ClassNotFoundException(name); + } + if (name.startsWith(RELOADED_PACKAGE)) { + synchronized (getClassLoadingLock(name)) { + var loaded = findLoadedClass(name); + if (loaded == null) { + loaded = defineClass(name, readBytes(name)); + } + if (resolve) { + resolveClass(loaded); + } + return loaded; + } + } + return super.loadClass(name, resolve); + } + + private Class defineClass(String name, byte[] bytes) { + return defineClass(name, bytes, 0, bytes.length); + } + + private byte[] readBytes(String name) throws ClassNotFoundException { + var resource = name.replace('.', '/') + ".class"; + try (var stream = getParent().getResourceAsStream(resource)) { + if (stream == null) { + throw new ClassNotFoundException(name); + } + return stream.readAllBytes(); + } catch (IOException e) { + throw new ClassNotFoundException(name, e); + } + } + } + // Helper methods to access protected fields using reflection + private Object getOidcUserConverter(OidcUserService oidcUserService) + throws Exception { + var field = OidcUserService.class.getDeclaredField("oidcUserConverter"); + field.setAccessible(true); + return field.get(oidcUserService); + } + private String getDefaultTargetUrl( VaadinSavedRequestAwareAuthenticationSuccessHandler handler) throws Exception {