
Spring Security SSO With Group and Role Mapping
Implement Spring Security SSO with group and role mapping using GrantedAuthoritiesMapper and OIDC claims for secure access control.
When integrating Spring Boot applications with Single Sign-On (SSO) providers like Keycloak or Okta, a disconnect often exists between the identity provider’s authorization model and Spring Security’s permission system. While IdPs return groups in the OpenID Connect (OIDC) ID token, Spring Security does not automatically translate these into actionable permissions. This guide on spring security sso integration details how to bridge this gap using GrantedAuthoritiesMapper to ensure users have the correct access levels.
The Mechanism Gap in Default OIDC Processing
Spring Security’s OAuth2 Login support relies on the OAuth2LoginAuthenticationFilter to process the authentication response from the identity provider. However, the filter delegates the heavy lifting to an authentication provider, such as OAuth2LoginAuthenticationProvider or OidcAuthorizationCodeAuthenticationProvider. These providers perform the actual token exchange with the IdP and extract the user principal. The claims from the ID token—such as sub, email, and groups—are stored in the OAuth2AuthenticatedPrincipal.
The default implementation does not interpret the semantic meaning of these claims regarding application access control. It treats groups as arbitrary metadata. If your IdP returns:
{
"sub": "user123",
"email": "user@example.com",
"groups": ["engineering", "leads"]
}Spring Security, by default, does not convert "leads" into a ROLE_LEAD authority. Consequently, any @PreAuthorize("hasRole('LEAD')") annotation in your application will fail, even if the IdP explicitly granted that role. This is not a bug; it is a design choice. Spring Security cannot know which claim contains role data (it could be groups, roles, realm_access.roles, or a custom claim) or how to map them to your internal domain model.
The Solution: GrantedAuthoritiesMapper
The mechanism for resolving this ambiguity is the GrantedAuthoritiesMapper interface. This interface is invoked during the authentication process, specifically when constructing the Authentication object. It accepts the collection of GrantedAuthority objects derived from the principal and returns a modified collection.
Effective group role mapping is critical for aligning IdP identities with application permissions. By implementing this interface, you intercept the raw authorities and transform them into specific SimpleGrantedAuthority instances that Spring’s expression language understands.
The contract is simple:
public interface GrantedAuthoritiesMapper {
Collection<? extends GrantedAuthority> mapAuthorities(
Collection<? extends GrantedAuthority> authorities);
}This decouples your application’s role definitions from the IdP’s group naming conventions.
Worked Scenario: Mapping Groups to Roles
Consider an application where users are categorized into engineering and leads groups by the IdP. We need to map leads to ROLE_LEAD and all other groups to ROLE_USER. We also need to handle the case where a user might belong to multiple groups.
Here is the implementation of a custom mapper:
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.*;
@Configuration
public class SecurityConfig {
@Bean
public GrantedAuthoritiesMapper userAuthoritiesMapper() {
return (authorities) -> {
Set<GrantedAuthority> mappedAuthorities = new HashSet<>();
// Extract the 'groups' claim from the OIDC user
// Note: In practice, you should handle cases where the claim might not exist
Optional<OidcUserAuthority> oidcAuthority = authorities.stream()
.filter(OidcUserAuthority.class::isInstance)
.map(OidcUserAuthority.class::cast)
.findFirst();
if (oidcAuthority.isPresent()) {
OidcUserInfo userInfo = oidcAuthority.get().getUserInfo();
List<String> groups = userInfo.getClaimAsStringList("groups");
if (groups != null) {
for (String group : groups) {
if ("leads".equalsIgnoreCase(group)) {
mappedAuthorities.add(new SimpleGrantedAuthority("ROLE_LEAD"));
} else {
// Map all other groups to a base user role
mappedAuthorities.add(new SimpleGrantedAuthority("ROLE_USER"));
}
}
}
}
// Fallback: if no specific group mapping occurred, ensure a base role exists
if (mappedAuthorities.isEmpty()) {
mappedAuthorities.add(new SimpleGrantedAuthority("ROLE_USER"));
}
return mappedAuthorities;
};
}
}How This Works Mechanically
- Injection: Spring’s
OAuth2LoginAuthenticationFilterdetects theGrantedAuthoritiesMapperbean in the context. - Extraction: After the IdP response is processed, the filter creates an initial list of authorities. These might include standard scopes (like
openid,profile) or custom claims if configured. - Mapping: The filter calls
mapAuthorities()with this initial list. Our implementation extracts thegroupsclaim from theOidcUserInfo. The mapper extracts specific oidc claims from the authenticated principal to determine the final role set. - Transformation: We iterate through the groups. If a group matches
"leads", we addROLE_LEAD. Otherwise, we addROLE_USER. - Result: The resulting
Authenticationobject now contains the correct authorities, allowing@PreAuthorize("hasRole('LEAD')")to evaluate totruefor lead engineers.
Security Implications and Validation
While GrantedAuthoritiesMapper provides the flexibility to map roles, it also introduces a responsibility: validation. You must ensure that the claim you are reading actually exists and is in the expected format. If the IdP is misconfigured and returns a malformed groups claim, or if an attacker attempts to manipulate the token (though OIDC tokens are signed, so tampering is detectable), the mapper should fail gracefully or reject the authentication.
It is also important to note that relying solely on client-side claim parsing without verifying the token signature is insufficient. Spring Security’s OIDC support handles signature verification automatically via the JwtDecoder (see Spring Security Reference Documentation). However, the mapping logic itself should be deterministic and auditable. Hard-coding role mappings in the application code, as shown above, ensures that the access control logic is version-controlled and reviewed, rather than being configured dynamically in the IdP without corresponding application changes.
A common mistake I’ve seen in production environments is assuming that the groups claim is always present. Developers often forget to add null checks or fallback logic, causing the entire authentication process to crash with a NullPointerException if a user is logged in without group information. Always ensure your mapper is resilient to missing or empty claims.
Conclusion
Implementing SSO with Spring Security requires explicit handling of role mapping. The default behavior leaves access control undefined, assuming the developer will provide the bridge between identity and authorization. By leveraging GrantedAuthoritiesMapper, you can cleanly separate the IdP’s group structure from your application’s role hierarchy, ensuring secure and maintainable access control.
Related posts
Spring Security SAML: A Working Integration
A practical guide to integrating Spring Security SAML with Spring Boot, covering SAML2 relying party configuration, service provider setup, and metadata handling.
SecurityFilterChain in Spring Security 6
A technical walkthrough of configuring SecurityFilterChain in Spring Security 6 using the Lambda DSL and RequestMatchers for Java developers.
Spring Boot Keycloak: The Current Way
A guide to securing Spring Boot applications with Keycloak using the current OAuth2 Resource Server and JWT Decoder standards, replacing deprecated adapters.