feat: map Keycloak realm roles to Spring RBAC
This commit is contained in:
@@ -27,4 +27,9 @@ public class ApiController {
|
||||
response.put("audience", jwt.getAudience());
|
||||
return response;
|
||||
}
|
||||
|
||||
@GetMapping("/admin")
|
||||
public Map<String, String> adminEndpoint() {
|
||||
return Map.of("status", "ok", "authorization", "admin-role");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.example.keycloakpattern;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
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.jwt.Jwt;
|
||||
|
||||
final class KeycloakRealmRoleConverter
|
||||
implements Converter<Jwt, Collection<GrantedAuthority>> {
|
||||
|
||||
@Override
|
||||
public Collection<GrantedAuthority> convert(Jwt jwt) {
|
||||
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
|
||||
if (realmAccess == null || !(realmAccess.get("roles") instanceof Collection<?> roles)) {
|
||||
return List.of();
|
||||
}
|
||||
return roles.stream()
|
||||
.filter(String.class::isInstance)
|
||||
.map(String.class::cast)
|
||||
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
|
||||
.map(GrantedAuthority.class::cast)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
@@ -25,12 +26,21 @@ public class SecurityConfig {
|
||||
.authorizeHttpRequests(authorize -> authorize
|
||||
.requestMatchers("/actuator/health", "/actuator/health/**", "/api/public")
|
||||
.permitAll()
|
||||
.requestMatchers("/api/admin")
|
||||
.hasRole("admin-role")
|
||||
.anyRequest()
|
||||
.authenticated())
|
||||
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
|
||||
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
|
||||
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))
|
||||
.build();
|
||||
}
|
||||
|
||||
private JwtAuthenticationConverter jwtAuthenticationConverter() {
|
||||
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
|
||||
converter.setJwtGrantedAuthoritiesConverter(new KeycloakRealmRoleConverter());
|
||||
return converter;
|
||||
}
|
||||
|
||||
@Bean
|
||||
CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@@ -40,4 +41,19 @@ class ApiSecurityTest {
|
||||
.andExpect(jsonPath("$.subject").value("test-subject"))
|
||||
.andExpect(jsonPath("$.username").value("regular-user"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void regularUserCannotCallAdminEndpoint() throws Exception {
|
||||
mockMvc.perform(get("/api/admin").with(jwt()
|
||||
.authorities(new SimpleGrantedAuthority("ROLE_user-role"))))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminRoleCanCallAdminEndpoint() throws Exception {
|
||||
mockMvc.perform(get("/api/admin").with(jwt()
|
||||
.authorities(new SimpleGrantedAuthority("ROLE_admin-role"))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.authorization").value("admin-role"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.example.keycloakpattern;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
class KeycloakRealmRoleConverterTest {
|
||||
|
||||
private final KeycloakRealmRoleConverter converter =
|
||||
new KeycloakRealmRoleConverter();
|
||||
|
||||
@Test
|
||||
void mapsRealmRolesWithExactlyOneRolePrefix() {
|
||||
Jwt jwt = new Jwt(
|
||||
"token",
|
||||
Instant.now(),
|
||||
Instant.now().plusSeconds(60),
|
||||
Map.of("alg", "none"),
|
||||
Map.of("sub", "subject", "realm_access", Map.of(
|
||||
"roles", List.of("admin-role", "user-role")
|
||||
))
|
||||
);
|
||||
|
||||
assertThat(converter.convert(jwt))
|
||||
.extracting("authority")
|
||||
.containsExactly("ROLE_admin-role", "ROLE_user-role");
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingRealmAccessProducesNoAuthorities() {
|
||||
Jwt jwt = new Jwt(
|
||||
"token",
|
||||
Instant.now(),
|
||||
Instant.now().plusSeconds(60),
|
||||
Map.of("alg", "none"),
|
||||
Map.of("sub", "subject")
|
||||
);
|
||||
|
||||
assertThat(converter.convert(jwt)).isEmpty();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user