Skip to content

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

Merged
mshabarov merged 11 commits into
mainfrom
feat/keycloak-role-mapping
Sep 21, 2026
Merged

mshabarov merged 11 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 a user's roles in the access token, so Vaadin applications never saw them and role-based access control did not work. This adds an opt-in switch, VaadinSecurityConfigurer.keycloakRoleMapping(), that reads those roles and turns them into Spring Security authorities.

What changed

Fully backward compatible. Nothing changes unless an application calls the new keycloakRoleMapping() method. It is off by default.

  • New public class KeycloakOidcUserMapper, ported from SSO Kit. It decodes the access token and adds the realm roles (realm_access), the client roles granted to the current client id (resource_access), and the token scopes as SCOPE_ authorities. Roles that resource_access grants to other clients are ignored.
  • New VaadinSecurityConfigurer.keycloakRoleMapping() opts one security filter chain in. It only works together with oauth2LoginPage(...); if no OAuth2 login page is configured, the configurer logs a warning and does nothing.
  • When enabled, the configurer creates an OidcUserService and shares it as a shared object, so it can be inspected or replaced. An application that owns its own OidcUserService should leave the switch off and set the mapper with setOidcUserConverter(...) instead.
  • Differences from the SSO Kit version: the JWT decoder of a client registration is built once and reused instead of on every login, the role prefix comes from VaadinRolePrefixHolder (resolved when a user is mapped, so a prefix defined by the filter chain itself is picked up) instead of a hardcoded ROLE_, and a login is never failed when the access token cannot be decoded — the user is simply mapped without role authorities.
  • The wiring lives in a separate package-private class so that VaadinSecurityConfigurer still loads for applications without the optional spring-security-oauth2-client dependency.

Use case

An application uses Keycloak for login and protects its admin views with @RolesAllowed("admin"). Today those views stay blocked even for users who have the admin realm role, because the role never reaches Spring Security. Adding one call fixes it.

@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") // now matches the "admin" realm role in Keycloak
public class AdminView extends VerticalLayout {
}

An application that builds its own OidcUserService can use the mapper directly instead:

var oidcUserService = new OidcUserService();
oidcUserService.setOidcUserConverter(new KeycloakOidcUserMapper());

API Changes

com.vaadin.flow.spring.security.KeycloakOidcUserMapper

// Added
public class KeycloakOidcUserMapper implements Converter<OidcUserSource, OidcUser>
public KeycloakOidcUserMapper() // uses the ROLE_ prefix
public KeycloakOidcUserMapper(String rolePrefix) // null means ROLE_
public OidcUser convert(OidcUserSource userSource)

com.vaadin.flow.spring.security.VaadinSecurityConfigurer

// Added
public VaadinSecurityConfigurer keycloakRoleMapping() // opts in to Keycloak role mapping, off by default

Test summary

  • Realm roles, client roles for the current client, and token scopes become authorities; roles belonging to other clients are left out.
  • The role prefix is configurable, applies to roles only (not scopes), and a prefix set by the security filter chain is picked up even though it is known only after the chain is built.
  • The authenticated user keeps its userinfo claims and can take its name from a userinfo attribute that is missing from the ID token.
  • Login still succeeds without role authorities when the access token is not a decodable JWT, or when the client registration has no JWK set URI or no issuer URI.
  • The JWT decoder is built only once per client registration.
  • The opt-in only takes effect together with an OAuth2 login page, and VaadinSecurityConfigurer still loads when the optional spring-security-oauth2-client dependency is absent.

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.
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Test Results

 1 456 files  + 1   1 540 suites  +1   1h 33m 55s ⏱️ + 7m 46s
11 951 tests +12  11 883 ✅ +12  68 💤 ±0  0 ❌ ±0 
12 269 runs  +12  12 201 ✅ +12  68 💤 ±0  0 ❌ ±0 

Results for commit 3757644. ± Comparison against base commit ffe584b.

♻️ This comment has been updated with latest results.

@heruan heruan left a comment

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.

Suggestions to simplify the port, nothing blocking.

Stop reusing an OidcUserService shared with the security builder: Spring
Security looks up a bean of that type rather than a shared object, so the
contract was new here and nothing set it, and taking over a service the
application owns is surprising. The mapping now always creates its own
service and shares it as an output, and a custom service is pointed at
setOidcUserConverter instead.

Also stop failing the login for a client registration configured with
explicit endpoints, where the JWK set URI and the issuer URI are null:
building the decoder threw an IllegalArgumentException, which is not a
JwtException and so escaped the catch that maps a user without roles.
A client registration without a JWK set URI has no decoder, and
computeIfAbsent stores no mapping for a null result, so one was built
again on every login. Store an Optional so the lookup happens once, as
the javadoc says it does.

Also cover the client registration that has a JWK set URI but no issuer
URI: building the default validator for a null issuer throws an
IllegalArgumentException, which no test reached.
heruan
heruan previously approved these changes Sep 15, 2026
@mshabarov

Copy link
Copy Markdown
Contributor

@totally-not-ai resolve conflicts with main and revert changes in package-lock files that are unrelevant.

…pping

# Conflicts:
#	vaadin-spring/src/test/java/com/vaadin/flow/spring/security/VaadinSecurityConfigurerTest.java
@totally-not-ai

Copy link
Copy Markdown
Contributor Author

@mshabarov Both done.

Merged main in and resolved the one conflict, in VaadinSecurityConfigurerTestmain and this branch had each appended tests at the end of the class. Both sets are kept: the new anonymous sub-resource and document request tests from main, and the classpath test from here, with its class loader moved down next to the other test helpers. The module's tests pass after the merge.

On the lock files: there are none left in the diff. What the merge did drag in was a copyright-year rewrite across the 123 freshly imported flow-tests/vaadin-cdi-tests sources, applied by the formatter on commit. Those are restored to their main content, so the diff is back to the six vaadin-spring files this PR is actually about.

@Artur-

Artur- commented Sep 18, 2026

Copy link
Copy Markdown
Member

merge main

…pping

# Conflicts:
#	vaadin-spring/src/test/java/com/vaadin/flow/spring/security/VaadinSecurityConfigurerTest.java
@totally-not-ai

Copy link
Copy Markdown
Contributor Author

@Artur- Merged. main is now an ancestor of this branch, so the conflicts are gone.

Three conflicts, all in VaadinSecurityConfigurerTest, all of the "both sides appended at the same spot" kind: two imports and a block of test methods. Both sides are kept in each case — the new session-concurrency and expired-session tests from main, and the classpath test from here. The session management work that came in with main merged cleanly into the configurer itself, and both its VaadinExpiredSessionStrategy entry and the two entries from this PR are present in SpringClassesSerializableTest.

vaadin-spring passes after the merge, and the diff is still only the six files this PR adds to.

@sonarqubecloud

Copy link
Copy Markdown

@heruan heruan left a comment

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.

Re-approving after the merges from main. The six vaadin-spring files are unchanged since the last review, the conflict resolution in VaadinSecurityConfigurerTest kept both sides, and the diff no longer touches any lock file.

@mshabarov
mshabarov added this pull request to the merge queue Sep 21, 2026
Merged via the queue into main with commit ff3d9a7 Sep 21, 2026
49 checks passed
@mshabarov
mshabarov deleted the feat/keycloak-role-mapping branch September 21, 2026 13:38
@github-project-automation github-project-automation Bot moved this from 🔎Iteration reviews to Done in Vaadin Flow | Hilla | Kits ongoing work Sep 21, 2026
vaadin-bot added a commit to vaadin/docs that referenced this pull request Sep 21, 2026
Add a "Mapping Keycloak Roles to Authorities" section to the OAuth2
integration page, and document the keycloakRoleMapping() method and
OidcUserService shared object on the VaadinSecurityConfigurer
reference page.

Documents vaadin/flow#25627 (`375764465d2d84e51bfd9038528b964830be2d3c`).
@github-actions

Copy link
Copy Markdown
Contributor

Pull request created: #6110

Generated by Documentation Bot · agent · 96.3 AIC · ⌖ 6.84 AIC · ⊞ 11.7K

@github-actions

Copy link
Copy Markdown
Contributor

Documentation Bot: Draft documentation pull request for this change: #aw_docspr

Files updated:

  • articles/flow/integrations/spring/oauth2.adoc
  • articles/flow/security/vaadin-security-configurer.adoc

It was written from the state of this pull request as you see it now. Please review it and mark it ready for review.

Generated by Documentation Bot for #25627 · agent · 96.3 AIC · ⌖ 6.84 AIC · ⊞ 11.7K ·

Artur- pushed a commit to vaadin/docs that referenced this pull request Sep 22, 2026
## Summary
Documents the new opt-in Keycloak role mapping added in Vaadin 25.4.
Keycloak puts user roles into the access token, so Spring Security
ignores them and role checks fail silently; the docs now explain how to
turn the mapping on.

## What changed
Documentation only — no code, no behavior change.

**OAuth2 guide** (`articles/flow/integrations/spring/oauth2.adoc`): new
"Keycloak Role Mapping" section that explains why Keycloak roles don't
match by default, shows how to enable `keycloakRoleMapping()` on
`VaadinSecurityConfigurer`, and lists the authorities the mapping grants
(realm roles, client roles for the current client ID, `SCOPE_` scopes,
and the `OidcUserAuthority`). It also covers the role prefix, the JWKS
requirement for decoding the access token, the fallback when no roles
can be read, and the shared `OidcUserService`. A subsection shows how to
use `KeycloakOidcUserMapper` directly when the application builds its
own `OidcUserService`.

**Configurer reference**
(`articles/flow/security/vaadin-security-configurer.adoc`): adds
`keycloakRoleMapping()` to the configuration methods, and lists
`ClientRegistrationRepository` and `OidcUserService` among the shared
beans.

Documents vaadin/flow#25627.

---------

Co-authored-by: totally-not-ai[bot] <290682512+totally-not-ai[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Development

Successfully merging this pull request may close these issues.

3 participants