feat: map Keycloak realm roles to Spring RBAC

This commit is contained in:
donghyeon-ka
2026-07-25 16:35:28 +09:00
parent f566ed192c
commit 3e3de6c4c3
9 changed files with 202 additions and 2 deletions
@@ -27,4 +27,9 @@ public class ApiController {
response.put("audience", jwt.getAudience()); response.put("audience", jwt.getAudience());
return response; 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.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy; 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.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsConfigurationSource;
@@ -25,12 +26,21 @@ public class SecurityConfig {
.authorizeHttpRequests(authorize -> authorize .authorizeHttpRequests(authorize -> authorize
.requestMatchers("/actuator/health", "/actuator/health/**", "/api/public") .requestMatchers("/actuator/health", "/actuator/health/**", "/api/public")
.permitAll() .permitAll()
.requestMatchers("/api/admin")
.hasRole("admin-role")
.anyRequest() .anyRequest()
.authenticated()) .authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))
.build(); .build();
} }
private JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(new KeycloakRealmRoleConverter());
return converter;
}
@Bean @Bean
CorsConfigurationSource corsConfigurationSource() { CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration(); 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.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MockMvc;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
@SpringBootTest @SpringBootTest
@AutoConfigureMockMvc @AutoConfigureMockMvc
@@ -40,4 +41,19 @@ class ApiSecurityTest {
.andExpect(jsonPath("$.subject").value("test-subject")) .andExpect(jsonPath("$.subject").value("test-subject"))
.andExpect(jsonPath("$.username").value("regular-user")); .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();
}
}
+16
View File
@@ -0,0 +1,16 @@
# Keycloak realm roles to Spring authorization
`realm_access.roles`의 각 문자열을 `ROLE_` prefix가 붙은 Spring authority로
변환한다. `/api/admin``hasRole("admin-role")` 계약이므로 최종 authority는
`ROLE_admin-role`이다. `hasRole("ROLE_admin-role")`로 쓰면 prefix가 중복된다.
검증은 세 층으로 구성된다.
- converter 단위 테스트: role claim과 claim 부재
- MockMvc: regular 403, admin 200
- 실제 Authorization Code + PKCE login: Keycloak token의 realm role을
Spring Resource Server가 변환해 regular 403/admin 200을 반환
```sh
./scripts/verify-spring-role-mapping.sh
```
+2 -1
View File
@@ -4,7 +4,8 @@
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"test:pattern1": "node pattern1.mjs" "test:pattern1": "node pattern1.mjs",
"test:role-mapping": "node role-mapping.mjs"
}, },
"devDependencies": { "devDependencies": {
"playwright-core": "1.62.0" "playwright-core": "1.62.0"
+65
View File
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
const keycloakUrl = "http://localhost:8080";
const frontendUrl = "http://localhost:8088/";
async function accessToken(browser, username, password) {
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(frontendUrl);
const tokenResponse = page.waitForResponse((response) =>
response.url().includes("/protocol/openid-connect/token")
&& response.request().postData()?.includes("grant_type=authorization_code"),
);
await page.locator("#login").click();
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
const response = await tokenResponse;
assert.equal(response.status(), 200);
const token = (await response.json()).access_token;
await context.close();
return token;
}
async function adminStatus(token) {
return (
await fetch("http://localhost:8081/api/admin", {
headers: { Authorization: `Bearer ${token}` },
})
).status;
}
const regularPassword = process.env.REGULAR_USER_PASSWORD;
const adminPassword = process.env.ADMIN_USER_PASSWORD;
assert.ok(regularPassword && adminPassword);
const browser = await chromium.launch({
executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome",
headless: true,
args: ["--no-sandbox"],
});
try {
const regularToken = await accessToken(
browser,
"regular-user",
regularPassword,
);
const regularPayload = JSON.parse(
Buffer.from(regularToken.split(".")[1], "base64url").toString(),
);
assert.ok(regularPayload.realm_access.roles.includes("user-role"));
assert.equal(await adminStatus(regularToken), 403);
const adminToken = await accessToken(browser, "admin-user", adminPassword);
const adminPayload = JSON.parse(
Buffer.from(adminToken.split(".")[1], "base64url").toString(),
);
assert.ok(adminPayload.realm_access.roles.includes("admin-role"));
assert.equal(await adminStatus(adminToken), 200);
console.log("Spring RBAC verified: realm role -> ROLE_ authority -> 403/200");
} finally {
await browser.close();
}
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env sh
set -eu
set -a
. ./.env
set +a
docker compose down --volumes --remove-orphans
docker compose up --build -d --wait
npm --prefix e2e ci
REGULAR_USER_PASSWORD="$REGULAR_USER_PASSWORD" \
ADMIN_USER_PASSWORD="$ADMIN_USER_PASSWORD" \
npm --prefix e2e run test:role-mapping