From 3e3de6c4c339134d276b5eb3d2e88737c487612c Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 16:35:28 +0900 Subject: [PATCH] feat: map Keycloak realm roles to Spring RBAC --- .../keycloakpattern/ApiController.java | 5 ++ .../KeycloakRealmRoleConverter.java | 28 ++++++++ .../keycloakpattern/SecurityConfig.java | 12 +++- .../keycloakpattern/ApiSecurityTest.java | 16 +++++ .../KeycloakRealmRoleConverterTest.java | 46 +++++++++++++ docs/spring-role-mapping.md | 16 +++++ e2e/package.json | 3 +- e2e/role-mapping.mjs | 65 +++++++++++++++++++ scripts/verify-spring-role-mapping.sh | 13 ++++ 9 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/java/com/example/keycloakpattern/KeycloakRealmRoleConverter.java create mode 100644 backend/src/test/java/com/example/keycloakpattern/KeycloakRealmRoleConverterTest.java create mode 100644 docs/spring-role-mapping.md create mode 100644 e2e/role-mapping.mjs create mode 100755 scripts/verify-spring-role-mapping.sh diff --git a/backend/src/main/java/com/example/keycloakpattern/ApiController.java b/backend/src/main/java/com/example/keycloakpattern/ApiController.java index e234030..5b324d3 100644 --- a/backend/src/main/java/com/example/keycloakpattern/ApiController.java +++ b/backend/src/main/java/com/example/keycloakpattern/ApiController.java @@ -27,4 +27,9 @@ public class ApiController { response.put("audience", jwt.getAudience()); return response; } + + @GetMapping("/admin") + public Map adminEndpoint() { + return Map.of("status", "ok", "authorization", "admin-role"); + } } diff --git a/backend/src/main/java/com/example/keycloakpattern/KeycloakRealmRoleConverter.java b/backend/src/main/java/com/example/keycloakpattern/KeycloakRealmRoleConverter.java new file mode 100644 index 0000000..24179ca --- /dev/null +++ b/backend/src/main/java/com/example/keycloakpattern/KeycloakRealmRoleConverter.java @@ -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> { + + @Override + public Collection convert(Jwt jwt) { + Map 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(); + } +} diff --git a/backend/src/main/java/com/example/keycloakpattern/SecurityConfig.java b/backend/src/main/java/com/example/keycloakpattern/SecurityConfig.java index 41b68af..94582fc 100644 --- a/backend/src/main/java/com/example/keycloakpattern/SecurityConfig.java +++ b/backend/src/main/java/com/example/keycloakpattern/SecurityConfig.java @@ -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(); diff --git a/backend/src/test/java/com/example/keycloakpattern/ApiSecurityTest.java b/backend/src/test/java/com/example/keycloakpattern/ApiSecurityTest.java index 224287b..399b7b5 100644 --- a/backend/src/test/java/com/example/keycloakpattern/ApiSecurityTest.java +++ b/backend/src/test/java/com/example/keycloakpattern/ApiSecurityTest.java @@ -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")); + } } diff --git a/backend/src/test/java/com/example/keycloakpattern/KeycloakRealmRoleConverterTest.java b/backend/src/test/java/com/example/keycloakpattern/KeycloakRealmRoleConverterTest.java new file mode 100644 index 0000000..beae1a2 --- /dev/null +++ b/backend/src/test/java/com/example/keycloakpattern/KeycloakRealmRoleConverterTest.java @@ -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(); + } +} diff --git a/docs/spring-role-mapping.md b/docs/spring-role-mapping.md new file mode 100644 index 0000000..909433f --- /dev/null +++ b/docs/spring-role-mapping.md @@ -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 +``` diff --git a/e2e/package.json b/e2e/package.json index 06a4dd4..3f4a3a9 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -4,7 +4,8 @@ "version": "1.0.0", "type": "module", "scripts": { - "test:pattern1": "node pattern1.mjs" + "test:pattern1": "node pattern1.mjs", + "test:role-mapping": "node role-mapping.mjs" }, "devDependencies": { "playwright-core": "1.62.0" diff --git a/e2e/role-mapping.mjs b/e2e/role-mapping.mjs new file mode 100644 index 0000000..34b9efc --- /dev/null +++ b/e2e/role-mapping.mjs @@ -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(); +} diff --git a/scripts/verify-spring-role-mapping.sh b/scripts/verify-spring-role-mapping.sh new file mode 100755 index 0000000..71f18c4 --- /dev/null +++ b/scripts/verify-spring-role-mapping.sh @@ -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