Skip to content

feat(spring): add opt-in Keycloak role mapping to VaadinSecurityConfigurer - #25627

Open
totally-not-ai[bot] wants to merge 6 commits into
mainfrom
feat/keycloak-role-mapping
Open

feat(spring): add opt-in Keycloak role mapping to VaadinSecurityConfigurer#25627
totally-not-ai[bot] wants to merge 6 commits into
mainfrom
feat/keycloak-role-mapping

Conversation

@totally-not-ai

@totally-not-ai totally-not-ai Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Keycloak sends user roles in the access token, so Vaadin's role-based access control does not see them. This change adds VaadinSecurityConfigurer.keycloakRoleMapping(), which turns Keycloak realm and client roles into Spring Security role authorities for one security filter chain.

What changed

  • KeycloakOidcUserMapper (new, ported from SSO Kit): decodes the access token and maps the realm_access roles and the resource_access roles of the current client id to prefixed role authorities. Roles that belong to other clients are ignored. Access-token scopes become SCOPE_ authorities.
  • VaadinSecurityConfigurer.keycloakRoleMapping() (new): opts in to the mapper. It only works together with oauth2LoginPage(...); without a login page the configurer logs a warning and does nothing. If the application shares its own OidcUserService through HttpSecurity, that instance is reused instead of a new one.
  • KeycloakRoleMapping (new, package-private): holds the wiring to OidcUserService. spring-security-oauth2-client is an optional dependency, and the JVM verifies a class as a whole when it loads it, so naming those types inside VaadinSecurityConfigurer would break every application that does not have the dependency. Keeping them in a separate class means it is only loaded when the application asks for Keycloak role mapping.

Differences from the SSO Kit version:

  • The JWT decoder of a client registration is created once and reused, instead of being built on every login.
  • The role prefix comes from VaadinRolePrefixHolder instead of a hardcoded ROLE_, and is resolved when a user is mapped, because the prefix of the filter chain is only known after configure() has run.
  • An access token that is not a decodable JWT maps a user without roles instead of failing the login.
  • The OidcUserInfo is always kept on the resulting user, so userinfo claims are not lost when the client registration sets a user-name attribute.

Use case

An application logs users in with Keycloak, and admins are marked by a Keycloak realm role called admin. The developer wants @RolesAllowed("admin") on a view to just work, without writing a custom OidcUserService.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http.with(VaadinSecurityConfigurer.vaadin(),
                configurer -> configurer
                        .oauth2LoginPage("/oauth2/authorization/keycloak")
                        .keycloakRoleMapping())
                .build();
    }
}

@Route("admin")
@RolesAllowed("admin") // matches the Keycloak realm role "admin"
public class AdminView extends VerticalLayout {
}

API Changes

com.vaadin.flow.spring.security.KeycloakOidcUserMapper

// Added
public class KeycloakOidcUserMapper implements Converter<OidcUserSource, OidcUser>
public KeycloakOidcUserMapper() // prefixes roles with ROLE_
public KeycloakOidcUserMapper(String rolePrefix) // null means ROLE_
public OidcUser convert(OidcUserSource userSource)

com.vaadin.flow.spring.security.VaadinSecurityConfigurer

// Added
public VaadinSecurityConfigurer keycloakRoleMapping() // opt in to Keycloak role mapping, needs oauth2LoginPage(...)

Test summary

# Status What the test verifies Why it matters
1 Realm roles and the current client's roles become prefixed role authorities, scopes become SCOPE_ authorities, and an OidcUserAuthority is still present This is the whole point of the feature
2 Roles that resource_access grants for other clients produce no role authority Leaking another client's roles would grant access the user should not have
3 A custom role prefix is applied to roles but not to scopes A wrong prefix silently breaks every @RolesAllowed check
4 An access token that is not a decodable JWT maps a user without roles instead of throwing A non-Keycloak or opaque token must not break the login
5 With a user-name attribute configured, the user keeps its OidcUserInfo and userinfo claims, and getName() resolves Dropping userinfo lost all claims and failed the login when the name is only in the userinfo response
6 The JWT decoder is created once per client registration and reused across logins Building a decoder per login fetches the JWK set every time
7 keycloakRoleMapping() with an OAuth2 login page installs the mapper into the chain's OidcUserService Without this the opt-in does nothing
8 keycloakRoleMapping() without an OAuth2 login page configures no OidcUserService Must not half-configure a chain that has no OIDC login
9 An OidcUserService shared on HttpSecurity is reused, not replaced An application's own customized service must survive
10 The mapper picks up the filter chain's role prefix set after the chain is built The prefix is unknown at configure time; resolving too early yields ROLE_
11 VaadinSecurityConfigurer loads and verifies through a class loader that hides spring-security-oauth2-client Any reference to the optional dependency crashes apps that do not use OAuth2
  • KeycloakOidcUserMapperTest.convert_realmAndClientRolesAndScopesMapped — 1
  • KeycloakOidcUserMapperTest.convert_otherClientRolesAndMissingClaimsIgnored — 2
  • KeycloakOidcUserMapperTest.convert_customRolePrefixAppliedToRolesOnly — 3
  • KeycloakOidcUserMapperTest.convert_accessTokenNotAJwt_mappedWithoutRoles — 4
  • KeycloakOidcUserMapperTest.convert_userNameAttributeUsedAsName_userInfoRetained — 5
  • KeycloakOidcUserMapperTest.convert_decoderCreatedOncePerClientRegistration — 6
  • VaadinSecurityConfigurerTest.keycloakRoleMapping_withOAuth2LoginPage_oidcUserServiceMapsRoles — 7
  • VaadinSecurityConfigurerTest.keycloakRoleMapping_withoutOAuth2LoginPage_notConfigured — 8
  • VaadinSecurityConfigurerTest.keycloakRoleMapping_sharedOidcUserService_isReused — 9
  • VaadinSecurityConfigurerTest.keycloakRoleMapping_rolePrefixOfChain_isUsedForRoles — 10
  • VaadinSecurityConfigurerTest.withoutOAuth2ClientOnClasspath_configurerStillLinks — 11

Deliberately untested: the real NimbusJwtDecoder built from a client registration (it needs a live JWK set endpoint, so the tests inject a decoder factory), the warning log text when no login page is configured, and an end-to-end login against a real Keycloak server.

Captures the intended contract for porting the SSO Kit Keycloak role
mapper into vaadin-spring: realm and client roles become prefixed role
authorities, other clients' roles are ignored, the role prefix is
configurable, and an access token that is not a decodable JWT degrades
to a user without role authorities instead of failing the login.

The test does not compile yet - KeycloakOidcUserMapper is added once the
opt-in mechanism is agreed on.
…gurer

Keycloak carries realm and client roles in the access token, so they are
not part of the OidcUser that OidcUserService builds and role-based
access control does not see them. KeycloakOidcUserMapper, ported from
SSO Kit, decodes the access token and maps those roles, and
VaadinSecurityConfigurer.keycloakRoleMapping() opts in to it for a
single security filter chain.

Compared to the SSO Kit version, the mapper reuses the JWT decoder of a
client registration instead of building one per login, takes the role
prefix from VaadinRolePrefixHolder instead of hardcoding ROLE_, and maps
a user without roles rather than failing the login when the access token
is not a decodable JWT.
The mapper built DefaultOidcUser without the OidcUserInfo when the client
registration configures a user-name attribute, which is the normal
Keycloak setup. That dropped every userinfo claim from the authenticated
user, and failed the login outright when the name attribute is only in
the userinfo response and not in the ID token.

Also resolve the role prefix when a user is mapped rather than while the
filter chain is being built, since a prefix that comes from the chain
itself is only known to VaadinRolePrefixHolder after configure() has run.
"integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
"cpu": [
"arm"
],

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file should not change in this PR

totally-not-ai Bot added a commit to vaadin/docs that referenced this pull request Sep 10, 2026
…ions

vaadin/flow#25625 adds UidlExpiredSessionStrategy and makes
VaadinSecurityConfigurer install it by default, and vaadin/flow#25627 adds
KeycloakOidcUserMapper behind a keycloakRoleMapping() opt-in. Both were
ported from SSO Kit, so two of the migration gaps close.

Moves the two features out of the gaps section and into the migration steps
that need them, with a since badge for the version they arrive in and the
previous manual approach kept in a note for earlier versions. Updates the
feature mapping table and the checklist to match.
totally-not-ai Bot and others added 2 commits September 10, 2026 09:58
Referring to OidcUserService from VaadinSecurityConfigurer broke every
application that configures Vaadin security without the optional
spring-security-oauth2-client dependency: a class is verified as a whole
when it is loaded, and proving that an OidcUserService may be passed as
an OAuth2UserService made the verifier load types that were not there,
so building the filter chain failed with a NoClassDefFoundError.

Move the wiring to KeycloakRoleMapping, which is only loaded once an
application asks for Keycloak role mapping and therefore has the
dependency. The new test loads the configurer through a class loader that
hides the dependency, so the same mistake fails in the unit tests instead
of in the Spring Security integration tests.
@sonarqubecloud

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown
Contributor

Test Results

 1 441 files  + 1   1 525 suites  +1   1h 40m 37s ⏱️ + 2m 41s
12 062 tests +11  11 994 ✅ +11  68 💤 ±0  0 ❌ ±0 
12 380 runs  +11  12 312 ✅ +11  68 💤 ±0  0 ❌ ±0 

Results for commit dab7a97. ± Comparison against base commit bbfb634.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant