Spring Security와 Keycloak 연동 가이드

Spring 애플리케이션에 인증 및 권한 관리를 도입할 때 가장 흔히 고려되는 방식 중 하나가 OAuth2 기반 보안 체계입니다. 특히 마이크로서비스 환경에서는 개별 API에 대해 세분화된 접근 제어가 필요하기 때문에, 외부 인증 서버와의 안정적인 연동이 필수적입니다. Keycloak은 OAuth2와 OpenID Connect를 지원하는 오픈소스 인증 서버로, SSO(Single Sign-On), 사용자 관리, 역할 기반 접근 제어(RBAC) 등 보안 기능을 통합적으로 제공합니다. Spring Security와 연동하면 Keycloak의 인증 기능을 활용하면서도, 각 API에 대해 유연한 권한 제어 정책을 구현할 수 있습니다.

Spring MSA Demo 마이크로서비스를 기반으로, Spring Security와 Keycloak을 연동하여 JWT 기반 인증과 권한 처리를 구현하는 방법을 다음과 같은 순서로 설명합니다.

  1. 설치 환경
  2. Keycloak 클라이언트 및 권한 정책 설정
  3. Authorization API 테스트
  4. Spring MSA Demo 테스트
  5. Spring Security JWT 인증 처리
  6. API 경로 기반의 권한처리 설계
  7. 마무리

이 글을 작성하면서 사용한 코드는 GitHub의 react-keycloak-demo 저장소를 내려받아 확인할 수 있습니다.


1. 설치 환경

다음과 같은 설치 환경에서 Spring Security와 Keycloak을 연동해 보았습니다.

  • Kubernetes v1.30
  • Keycloak Server 25.0.4
  • Kustomize v1.30.0
  • Helm v3.17.1
  • Docker 27.3.1

※ 모든 구성 요소는 arm64(aarch64) Linux 아키텍처를 기준으로 합니다.

추가로, 인증 및 권한 처리 연동을 위한 데모 애플리케이션으로 DockerHub에 등록된 cnapcloud/spring-msa-demo:latest 이미지를 사용합니다.

Keycloak 설치 및 설정이 필요한 경우, 아래 블로그를 참고하세요


2. Keycloak 클라이언트 및 권한 정책 설정

Spring Boot 기반의 마이크로서비스에 REST API에 대해 인증 및 권한 관리를 위해 Keycloak admin console의 cnap Realm에서 다음과 같이 Clients, Realm Roles, Users, Authorization을 설정합니다.

Client 생성

Clients 메뉴로 이동하여 다음과 같이 Client를 생성합니다.

  1. General settings

    • Client Type: OpenID Connect
    • Client ID: msa
    • Client authentication: On
    • Client authorization: On
  2. Capability Config.

    • Direct Access Grants: ✔︎
  3. Login Settings

    • Home URL: https://localhost:8080

Realm Roles 생성

Realm roles 메뉴에서 다음 2개의 Role을 추가합니다.

  • editor
  • viewer

USER 생성

Users 메뉴로 이동하여 사용자를 등록하고, Role mapping 탭에서 Role을 다음과 같이 할당한다.

  • admin: editor Role
  • alice: editor Role, viewer Role

Authorization 설정

Clients 메뉴에서 msa Client를 선택하고 Authorization 탭으로 이동합니다.

  1. Scope 생성
    Authorization > Scopes 탭에서 다음과 같이 Scope를 생성합니다.

    • “read” Scope
      • Name: read
    • “write” Scope
      • Name: write
  2. Resource 생성
    Authorization > Resources 탭에서 다음과 같이 Resource를 생성합니다.

    • “/api/v1/project” Resource
      • Name: /api/v1/project
      • Display name: /api/v1/project
      • Type: urn:myapp:resources:api
      • URIs: /api/v1/project/*
      • Authorization Scopes: write
    • “/api/v2/project” Resource
      • Name: /api/v2/project
      • Display name: /api/v2/project
      • Type: urn:myapp:resources:api
      • URIs: /api/v2/project/*
      • Authorization Scopes: read

    Spring Security에서는 현재 요청된 Request의 URI를 기반으로 권한 허용 여부를 확인합니다.
    이를 위해 Resource의 Name을 URI의 Prefix로 설정합니다.
    Type은 동일 유형으로 모아서 Permission을 설정할 있도록 하기 위해 설정하였습니다.
    이렇게 설정하지 않으면 각 리소스마다 Permission을 개별적으로 지정해야 하므로 관리가 복잡해집니다.

  3. Role Policy 생성
    Authorization > Policies 탭에서 다음과 같이 Policy를 생성합니다.

    • “editor” Policy
      • Policy type에서 Role 선택
      • Name: editor-policy
      • Role: editor (Required field ✔︎)
    • “viewer” Policy
      • Policy type에서 Role 선택
      • Name: viewer-policy
      • Role: viewer (Required field ✔︎)
  4. Permission 생성
    Authorization > Permissions 탭에서 다음과 같이 Scope-based Permission을 생성합니다.

    • “editor” Permission
      • Name: editor-permission
      • Resource type: urn:myapp:resources:api
      • Authorization scopes: write
      • Policies: editor-policy
      • Decision Strategy: Affirmative
    • “viewer” Permission
      • Name: viewer-permission
      • Resource type: urn:myapp:resources:api
      • Authorization scopes: read
      • Policies: editor-policy, viewer-policy
      • Decision Strategy: Affirmative

Keycloak에서 Authorization 설정 시, Scope와 Permission 간의 관계 구성에 주의가 필요합니다. 동일한 Scope가 여러 Permission에 할당되어 있을 경우, 그 중 하나라도 Policy 평가를 통과하지 못하면 해당 Scope는 최종적으로 거부됩니다. 또한, 하나의 Permission에 여러 개의 Policy가 연결된 경우, 평가 결과는 설정된 Decision Strategy에 따라 달라집니다.

  • Unanimous: 모든 Policy가 통과해야 Permission 승인
  • Affirmative: 하나의 Policy라도 통과하면 Permission 승인

3. Authorization API 테스트

Keycloak은 기본적으로 UI Console를 통해 Authorization을 설정하고 테스트할 수 있지만, 실제 시스템에서 인증 및 인가 흐름이 정상적으로 작동하는지 확인하려면 API를 통한 직접 테스트가 훨씬 효과적입니다. 특히 다음과 같은 상황에서 Keycloak의 API 테스트는 필수입니다.

  • 정책(Permission, Policy)이 기대한 대로 동작하는지 확인할 때
  • 특정 사용자 또는 Role이 실제로 어떤 리소스에 접근 가능한지 검증할 때
  • Spring 애플리케이션 연동 전에 Keycloak 설정만으로 권한 시스템을 테스트하고 싶을 때

이 테스트는 디버깅과 정책 검증의 첫 단계이며, 애플리케이션에 적용될 보안 정책이 의도한 대로 동작하는지를 사전에 확인할 수 있어 운영 안정성을 크게 높일 수 있습니다.

먼저, API 테스트를 위해 아래와 같이 환경 변수를 설정한 후 진행합니다.

환경 변수 설정

export KEYCLOAK_URL=https://keycloak.cnap.dev
export REALM=cnap
export CLIENT=msa
export CLIENT_SECRET=ZVpV2w2D2QwOnDVinLHBX2blEwf8JL60
export USER=admin
export PASSWORD=password

Access Token 발급

사용자 인증을 통해 Access Token을 발급받는 절차입니다. 이 토큰은 이후 권한 요청(Authorization API)에 반드시 포함되어야 합니다

export ACCESS_TOKEN=$(curl -sk -X POST "${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/token" \
  -d grant_type=password \
  -d client_id=${CLIENT} \
  -d client_secret=${CLIENT_SECRET} \
  -d request_token_type=urn:ietf:params:oauth:token_type:access_token \
  -d username=${USER} \
  -d password=${PASSWORD} \
  -d scope='openid profile email' \
| jq -r .access_token)

echo "ACCESS_TOKEN: ${ACCESS_TOKEN}"

이렇게 발급된 토큰의 기본 유효 시간은 5분으로 설정되어 있으며, 이 시간이 지나면 재발급이 필요합니다. 유효 시간은 Realm 설정 또는 Client 설정을 통해 변경할 수 있습니다.

Role 기반 Permission 조회

현재 사용자에게 할당된 Role을 기반으로, 접근 가능한 리소스를 조회합니다.

# Request
curl -sk -X POST "${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/token" \
  -d grant_type=urn:ietf:params:oauth:grant-type:uma-ticket \
  -d client_id="${CLIENT}" \
  -d client_secret="${CLIENT_SECRET}" \
  -d audience="${CLIENT}" \
  -d response_mode="permissions" \
  -d submit_request=true \
  -H "Authorization: Bearer ${ACCESS_TOKEN}"|jq

# Response
[
  {
    "scopes": [
      "read",
      "write"
    ],
    "rsid": "be70aaef-75c0-46b6-828f-ac9feb7ed875",
    "rsname": "/api/v1/project"
  },
  {
    "scopes": [
      "read",
      "write"
    ],
    "rsid": "710ee007-f667-4f1d-bcfb-c1b79f1c53e5",
    "rsname": "/api/v2/project"
  },
  {
    "rsid": "7b129c96-f36c-411b-b85b-1f54400b5c54",
    "rsname": "Default Resource"
  }
]
l  

URI 기반 Permission 조회

특정 리소스 경로(예: /api/v2/project)에 대해, 어떤 Scope가 할당되어 있는지를 조회합니다.

# Request
curl -sk -X POST "${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/token" \
  -d grant_type=urn:ietf:params:oauth:grant-type:uma-ticket \
  -d client_id="${CLIENT}" \
  -d client_secret="${CLIENT_SECRET}" \
  -d audience="${CLIENT}" \
  -d response_mode="permissions" \
  -d submit_request=true \
  -d permission="/api/v2/project" \
  -H "Authorization: Bearer ${ACCESS_TOKEN}" |jq

# Response
[
  {
    "scopes": [
      "read",
      "write"
    ],
    "rsid": "710ee007-f667-4f1d-bcfb-c1b79f1c53e5",
    "rsname": "/api/v2/project"
  }
]

4. Spring MSA Demo 테스트

Spring MSA Demo는 앞서 구성한 환경을 기반으로 Keycloak을 활용한 인증 및 권한 제어 기능을 갖춘 마이크로서비스입니다. Docker를 통해 서비스를 실행한 뒤, 사용자 역할(Role)에 따라 API 접근 권한이 어떻게 달라지는지 확인해보겠습니다.

이 마이크로서비스는 클라우드 사업자의 테넌트 정보를 프로젝트 단위로 관리하는 기능을 제공합니다. 동일한 기능을 수행하는 두 가지 버전의 API가 존재하며, 접근 권한 처리 방식에 차이가 있습니다.

  • V1 API(/api/v1/**): 인증 없이 모든 사용자가 접근 가능
  • V2 API(/api/v2/**): 인증 필요하고 메서드별 권한 제어

마이크로서비스 실행

컨테이너 실행 전에 “/etc/hosts"에 Keycloak 서버 도메인을 등록한 후, 아래 명령어로 Docker 컨테이너를 실행합니다.

docker run --rm -p 8080:8080 \
  -e KEYCLOAK_REALM_URL="${KEYCLOAK_URL}/realms/${REALM}" \
  -e KEYCLOAK_CLIENT_ID="${CLIENT}" \
  -e KEYCLOAK_CLIENT_SECRET="${CLIENT_SECRET}" \
  --add-host=keycloak.cnap.dev:192.168.64.2 \
  cnapcloud/spring-msa-demo:latest

브라우저에서 아래 주소로 접속해 Swagger API 문서를 확인합니다.

http://localhost:8080/swagger-ui/index.html

V1 API 테스트

“/api/v1/project” API는 Access token 없어도 정상적으로 프로젝트가 조회됩니다.

# Request
curl -sX GET http://localhost:8080/api/v1/project


# Response
{
  "content": [
    {
      "id": "project-06",
      "name": "sample-project-06",
      "region": "us-east-1",
      "access_key": "FKIBNYYFODNN7EXEXAMPLE6",
      "secret_key": "dJalrXUtnFEMI/V7MDENG/bPxRfiCYEXAMPLEKEY6",
      "enabled": true,
      "created_by": "123",
      "created_date": "2024-12-30T17:37:03",
      "last_modified_by": "123",
      "last_modified_date": "2024-12-30T17:37:03"
    },
    :
  ]
}

V2 API 테스트

/api/v2/** 경로에 해당하는 API는 인증이 필수이며, 사용자에게 부여된 역할에 따라 조회, 생성, 수정, 삭제 등의 작업에 대한 접근 권한이 세분화되어 관리됩니다.

  • editor 또는 viewer 역할을 가진 사용자는 read 권한을 보유하며, 자원에 대한 조회 작업을 수행할 수 있습니다.
  • editor 역할을 가진 사용자는 write 권한을 보유하며, 자원에 대한 생성, 수정, 삭제 작업을 수행할 수 있습니다.

다음과 같이 V2 API에 대해 Access token 없이 호출해 보면 다음과 같이 인증 오류가 발생하는 것을 볼 수 있습니다.

# Request
curl -sX GET http://localhost:8080/api/v2/project

# Response
{
  "error": "unauthorized",
  "message": "Full authentication is required to access this resource"
}

admin 사용자로 Access token을 발급받아 조회하면 정상적으로 프로젝트로 목록이 조회가 됩니다.

## Access Token 발급
export USER=admin
export ACCESS_TOKEN=... # Keycloak에서 토큰 발급 후 입력

# Request
curl -sX GET http://localhost:8080/api/v2/project \
-H "Authorization: Bearer ${ACCESS_TOKEN}" |jq

# Response
{
  "content": [
    {
      "id": "project-06",
      "name": "sample-project-06",
      "region": "us-east-1",
      "access_key": "FKIBNYYFODNN7EXEXAMPLE6",
      "secret_key": "dJalrXUtnFEMI/V7MDENG/bPxRfiCYEXAMPLEKEY6",
      "enabled": true,
      "created_by": "123",
      "created_date": "2024-12-30T17:37:03",
      "last_modified_by": "123",
      "last_modified_date": "2024-12-30T17:37:03"
    },
    :
  ]
}

다시, 이 Access token으로 삭제를 요청해보면 정상적으로 프로젝트가 삭제되는 것을 볼 수 있습니다.

# Request
curl -sX DELETE http://localhost:8080/api/v2/project/project-02 -I \
-H "Authorization: Bearer ${ACCESS_TOKEN}"

# Response
HTTP/1.1 200 OK

이제 alice 사용자로 Access Token을 받아 /api/v2/project를 조회해 보겠습니다.

export USER=alice
export ACCESS_TOKEN=... # Keycloak에서 토큰 발급 후 입력

# Request
curl -sX GET http://localhost:8080/api/v2/project -H "Authorization: Bearer ${ACCESS_TOKEN}" -I

# Response
HTTP/1.1 200 OK

다시, 이 Access token을 가지고 프로젝트를 지우려 시도하면 401 에러가 발생하는 것을 볼 수 있습니다.

# Request
curl -X DELETE http://localhost:8080/api/v2/project/project-03 \
-H "Authorization: Bearer ${ACCESS_TOKEN}" |jq

# Response
{
  "path": "uri=/api/v2/project/project-03",
  "error": "Forbidden",
  "message": "Access Denied",
  "timestamp": "2025-07-30T13:12:44",
  "status": 403
}

5. Spring Security JWT 인증 처리

Spring MSA Demo 마이크로서비스에서 Keycloak과 Spring Security를 연동하여 JWT 기반 인증 및 권한 처리를 구현하는 과정을 자세히 살펴보겠습니다.
Spring Security의 JWT 인증에 대한 기본 흐름을 살펴본 후, Keycloak 연동을 위해 커스터마이징한 부분을 자세히 다루겠습니다.

JWT 인증 흐름

  1. 클라이언트 API → HTTP 요청 전송 (Authorization: Bearer )

  2. 서블릿 필터 체인
    → SecurityFilterChain 진입

  3. SecurityFilterChain → 등록된 필터 중 BearerTokenAuthenticationFilter 실행

  4. BearerTokenAuthenticationFilter
    ① BearerTokenResolver로부터 JWT 추출
    → 호출: bearerTokenResolver.resolve(request)
    ② 추출된 JWT로 BearerTokenAuthentication 생성
    ③ authenticationManager.authenticate(authRequest) 호출

  5. AuthenticationManager → AuthenticationProvider 탐색 및 위임

  6. JwtAuthenticationProvider
    ① JWT 디코딩: jwtDecoder.decode(token)
    ② 권한 변환: jwtAuthenticationConverter.convert(jwt)
    ③ JwtAuthenticationToken 생성 (Authentication 반환)

  7. BearerTokenAuthenticationFilter → 인증 성공 시 SecurityContext에 Authentication 저장

  8. SecurityContextHolder
    → 현재 스레드에 SecurityContext 저장

JWT 인증 커스터마이징

Spring MSA Demo 마이크로서비스에 적용된 JWT 인증에 대한 보안 구성은 다음과 같습니다. 공통 보안 설정은 AbstractSecurityConfig 클래스로 분리하여 재사용성과 유지보수성을 높였습니다.

dnsmasq web ui

실제 인증 처리를 담당하는 모듈은 AbstractSecurityConfig를 상속한 SecurityConfig에서 구현됩니다. AbstractSecurityConfig에서는 JWT 토큰을 검증하고 권한 정보를 변환하기 위한 JwtDecoder와 JwtAuthenticationConverter 빈을 생성합니다. 이 구성 요소들은 SecurityConfig 클래스에서 HttpSecurity 설정을 통해 등록되며, 내부적으로는 Spring Security의 JwtAuthenticationProvider에 설정되어 인증 처리를 담당합니다.

Keycloak 서버가 자제 서명된 인증서를 사용하고 있는 경우, JWK URI에 접근할 때 SSL 검증 오류가 발생합니다. 이를 우회하기 위해 새로 JwtDecoder를 정의하고 공개키를 직접 가져오는 방식으로 KeycloakPublicKeyProvider를 구현하였습니다. 또한 Keycloak 토큰의 역할 및 권한 정보를 Spring Security에서 인식할 수 있도록, JwtAuthenticationConverter를 구현하여 ROLE_ 또는 SCOPE_ 접두어를 붙여 GrantedAuthority 형식으로 매핑하였습니다.

build.gradle

JWT 기반으로 Keycloak과 연동하여 인증 권한 처리를 위해서는 build.gradle에 다음과 같이 라이브러리를 추가해야 합니다.

implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server:6.3.3'

AbstractSecurityConfig 클래스

SecurityFilterChain을 구성할 때, 인증 및 권한 처리 로직이 반복되거나 공통적으로 사용되는 경우가 많습니다. 이를 효율적으로 관리하기 위해, 이 데모에서는 공통 보안 설정을 별도의 추상 클래스인 AbstractSecurityConfig 클래스로 분리하여 구현했습니다. 이 구성 클래스에서는 JWT 인증을 위한 핵심 기능들을 제공합니다.

  • KeycloakPublicKeyProvider: JWT 서명 검증용 공개키를 가져옵니다.
  • JwtDecoder: NimbusJwtDecoder를 사용하여 JWT 공개키를 가지고 JWT를 디코딩하고 기본 유효성 검사를 수행합니다.
  • JwtAuthenticationConverter: Keycloak의 권한 정보를 Spring Security의 GrantedAuthority로 변환합니다.
  • RptTokenFetcher: Keycloak Authorization에서 Permission을 가져옵니다.
@Slf4j
public abstract class AbstractSecurityConfig {
  :

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    applyCommonSettings(http);
    configureAuthorization(http);

    return http.build();
  }

  protected void applyCommonSettings(HttpSecurity http) throws Exception {
    http.addFilterBefore(new RequestContextFilter(), BearerTokenAuthenticationFilter.class);
    http.csrf(csrf -> csrf.disable()).cors(cors -> cors.configurationSource(corsConfigurationSource()));
    http.exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedEntryPoint()));
  }

  // This method should be implemented by subclasses to define specific authorization rules.
  protected abstract void configureAuthorization(HttpSecurity http) throws Exception;

  // It verifies the signature, ignores the issuer, and only validates basic time-related claims.
  @Bean
  public JwtDecoder jwtDecoder() {
    KeycloakPublicKeyProvider KeycloakPublicKeyProvider = new KeycloakPublicKeyProvider(realmUrl);
    RSAPublicKey publicKey = KeycloakPublicKeyProvider.getKeycloakPublicKey();
    NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withPublicKey(publicKey).build();
    OAuth2TokenValidator<Jwt> defaultValidator = JwtValidators.createDefault();
    jwtDecoder.setJwtValidator(defaultValidator);

    return jwtDecoder;
  }

  @Bean
  public JwtAuthenticationConverter KeycloakJwtAuthenticationConverter() {
    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(new KeycloakAuthorizationConverter(rptTokenFetcher()));

    return converter;
  }

  protected RptTokenFetcher rptTokenFetcher() {
    RptTokenFetcher rptTokenFetcher = new RptTokenFetcher(realmUrl, clientId, clientSecret);

    rptTokenFetcher.loadPathPrefixes(resourceSet);
    return rptTokenFetcher;
  }

  protected CorsConfigurationSource corsConfigurationSource() { ... }

  private AuthenticationEntryPoint unauthorizedEntryPoint() { ... }
}

위 코드에서 주목할 점은 RequestContextFilter가 BearerTokenAuthenticationFilter보다 먼저 실행되도록 설정되었다는 것입니다. RequestContextFilter는 요청된 API 경로를 ThreadLocal에 저장합니다. 이후 RptTokenFetcher는 ThreadLocal에 저장된 경로를 기반으로 리소스를 찾고, 해당 리소스에 대한 접근 권한을 Keycloak에서 조회합니다. 이렇게 조회된 Permission은 KeycloakAuthorizationConverter에서 SCOPE_ 접두어가 붙은 GrantedAuthority로 변환되어, 인증 객체에 추가됩니다.

SecurityConfig 클래스

SecurityConfig 클래스는 AbstractSecurityConfig를 상속받아, 실제 인증 처리를 담당하며 configureAuthorization 메소드에서 API별 인증 및 권한 정책을 정의합니다.

  • 프로파일 조건: @Profile("!test”)를 통해 테스트 환경에서는 보안 설정을 제외합니다.
  • 인증 및 권한 설정
    • /api/v1/**: 인증 없이 접근 가능
    • /api/v2/project/search: SCOPE_read 권한 설정
    • Swagger, Actuator, H2 콘솔 등은 모두 허용
  • OAuth2 리소스 서버 설정
    • JwtDecoder와 KeycloakJwtAuthenticationConverter bean 추가
@Configuration
@EnableMethodSecurity
@Profile("!test")
public class SecurityConfig extends AbstractSecurityConfig {

  protected SecurityConfig(@Value("${Keycloak.realm-url}") String realmUrl,
      @Value("${Keycloak.client-id}") String clientId, 
      @Value("${Keycloak.client-secret}") String clientSecret,
      @Value("${Keycloak.resource-set}") String[] resourceSet) {
    super(realmUrl, clientId, clientSecret, resourceSet);
  }
	
  protected  void configureAuthorization(HttpSecurity http) throws Exception {
    http
    .authorizeHttpRequests(auth -> auth
        .requestMatchers("/api/v1/**").permitAll()
        .requestMatchers("/api/v2/project/search").hasAuthority("SCOPE_read")
        .requestMatchers("/actuator/**", "/swagger-ui/**", "/api-docs/**", "/project/api-docs/**").permitAll()
        .requestMatchers(new RegexRequestMatcher(".*/api-docs/.*", null)).permitAll()
        .requestMatchers(antMatcher("/h2-console/**")).permitAll()
        .anyRequest().authenticated()
    )
    .oauth2ResourceServer(oauth2 -> oauth2
        .jwt(jwt -> jwt
            .decoder(jwtDecoder())
            .jwtAuthenticationConverter(KeycloakJwtAuthenticationConverter())
        )
    ); 
  }
}

KeycloakPublicKeyProvider 클래스

KeycloakPublicKeyProvider 클래스는 Keycloak의 JWKS URL에서 RSA 공개키를 가져와 캐싱하는 역할을 합니다. 이를 통해 인증 토큰 검증에 필요한 공개키를 제공할 수 있습니다.

public class KeycloakPublicKeyProvider {

  final private String jwksUrl;
  private RSAPublicKey cachedPublicKey = null;

  public KeycloakPublicKeyProvider(String realmUrl) {
    this.jwksUrl = realmUrl + "/protocol/openid-connect/certs";
    this.cachedPublicKey = fetchPublicKeyFromJwks();
  }

  public RSAPublicKey getKeycloakPublicKey() {
    return cachedPublicKey;
  }

  protected RSAPublicKey fetchPublicKeyFromJwks() {
    try {
      RestTemplate restTemplate = InsecureRestTemplateFactory.create();
      ObjectMapper objectMapper = new ObjectMapper();

      String jwksJson = restTemplate.getForObject(jwksUrl, String.class);
      JsonNode jwks = objectMapper.readTree(jwksJson);
      JsonNode key = jwks.get("keys").get(0);

      String n = key.get("n").asText();
      String e = key.get("e").asText();

      byte[] modulusBytes = Base64.getUrlDecoder().decode(n);
      byte[] exponentBytes = Base64.getUrlDecoder().decode(e);

      BigInteger modulus = new BigInteger(1, modulusBytes);
      BigInteger exponent = new BigInteger(1, exponentBytes);

      RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(modulus, exponent);
      KeyFactory keyFactory = KeyFactory.getInstance("RSA");

      return (RSAPublicKey) keyFactory.generatePublic(publicKeySpec);
    } catch (Exception ex) {
        throw new RuntimeException("Failed to fetch or parse JWKS", ex);
    }
  }
}

KeycloakAuthorizationConverter 클래스

KeycloakAuthorizationConverter 클래스는 JWT 토큰에서 Keycloak의 권한 정보를 추출해 Spring Security의 GrantedAuthority 컬렉션으로 변환하는 역할을 합니다.

  • Realm roles: realm_access 클레임의 roles 리스트를 ROLE_ 접두사와 함께 변환합니다.
  • Client roles: resource_access 내 myapp 클라이언트의 roles 리스트를 ROLE_ 접두사와 함께 변환합니다.
  • Groups: groups 클레임 리스트를 GROUP_ 접두사와 함께 변환합니다.
  • RPT 권한: RptTokenFetcher를 통해 받아온 permissions 내 scopes를 SCOPE_ 접두사와 함께 변환합니다.
public class KeycloakAuthorizationConverter implements Converter<Jwt, Collection<GrantedAuthority>> {

  private final RptTokenFetcher rptFetcher;

  public KeycloakAuthorizationConverter() {
    this.rptFetcher = null;
  }

  public KeycloakAuthorizationConverter(RptTokenFetcher rptFetcher) {
    this.rptFetcher = rptFetcher;
  }

  @Override
  public Collection<GrantedAuthority> convert(Jwt jwt) {
    Collection<GrantedAuthority> authorities = new ArrayList<>();

    // 1. Realm roles
    Map<String, Object> realmAccess = jwt.getClaim("realm_access");
    if (realmAccess != null && realmAccess.containsKey("roles")) {
      List<String> roles = (List<String>) realmAccess.get("roles");
      roles.forEach(role -> authorities.add(new SimpleGrantedAuthority("ROLE_" + role)));
    }

    // 2. Client roles
    Map<String, Object> resourceAccess = jwt.getClaim("resource_access");
    if (resourceAccess != null && resourceAccess.containsKey("myapp")) {
      Map<String, Object> client = (Map<String, Object>) resourceAccess.get("myapp");
      if (client != null && client.containsKey("roles")) {
        List<String> roles = (List<String>) client.get("roles");
        roles.forEach(role -> authorities.add(new SimpleGrantedAuthority("ROLE_" + role)));
      }
    }

    // 3. Groups
    List<String> groups = jwt.getClaimAsStringList("groups");
    if (groups != null) {
      for (String group : groups) {
        authorities.add(new SimpleGrantedAuthority("GROUP_" + group));
      }
    }

    // 4. RPT (access_token -> RPT)
    if (rptFetcher != null) {
      List<Map<String, Object>> permissions = rptFetcher.fetchRpt(jwt.getTokenValue());
      if (permissions != null) {
        for (Map<String, Object> permission : permissions) {
          List<String> scopes = (List<String>) permission.get("scopes");
          if (scopes != null) {
            scopes.forEach(scope -> authorities.add(new SimpleGrantedAuthority("SCOPE_" + scope)));
          }
        }
      }
    }

    return authorities;
  }
}

RptTokenFetcher 클래스

RptTokenFetcher 클래스는 주어진 리소스 경로에 대해 Keycloak UMA RPT 토큰을 발급받아 권한 정보를 조회합니다. 클라이언트 자격증명을 사용해 UMA 토큰을 요청하고, 응답받은 권한 목록을 JSON으로 파싱해 반환합니다.

@Slf4j
public class RptTokenFetcher {

  private final RestTemplate restTemplate;
  private final String tokenEndpoint;
  private final String clientId;
  private final String clientSecret;

  private Trie resourceSet = new Trie();

  public RptTokenFetcher(String realmUrl, String clientId, String clientSecret) {
    this.tokenEndpoint = realmUrl + "/protocol/openid-connect/token";
    this.clientId = clientId;
    this.clientSecret = clientSecret;
    this.restTemplate = InsecureRestTemplateFactory.create();
  }

  public void loadPathPrefixes(String[] resourceSet) {
    if (resourceSet == null) return;
    for (String prefix : resourceSet) {
      this.resourceSet.insert(prefix.trim());
    }
  }

  public List<Map<String, Object>> fetchRpt(String accessToken) {
    String path = RequestContextFilter.getCurrentRequestPath();

    try {
      String resourceName = resourceSet.longestPrefixMatch(path);
      if (resourceName == null) {
        log.info("No matching resource prefix found for path: {}", path);
        return null;
      }

      // Prepare headers
      HttpHeaders headers = new HttpHeaders();
      headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
      headers.setBearerAuth(accessToken);

      // Prepare request body
      MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
      body.add("grant_type", "urn:ietf:params:oauth:grant-type:uma-ticket");
      body.add("client_id", clientId);
      body.add("client_secret", clientSecret);
      body.add("audience", clientId);
      body.add("response_mode", "permissions");
      body.add("submit_request", "true");
      body.add("permission", resourceName);

      HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);

      // Send POST request
      ResponseEntity<String> response = restTemplate.postForEntity(tokenEndpoint, request, String.class);

      if (!response.getStatusCode().is2xxSuccessful()) {
        throw new IllegalStateException("Failed to obtain RPT. Status: " + response.getStatusCode());
      }
      log.info("Found permissions for {}: {}", path, response.getBody());

      // Parse JWT from response
      JSONArray permissionsArray = new JSONArray(response.getBody());
      List<Map<String, Object>> permissions = new ArrayList<>();

      for (int i = 0; i < permissionsArray.length(); i++) {
        JSONObject obj = permissionsArray.getJSONObject(i);
        permissions.add(obj.toMap());
      }

      // Decode JWT
      return permissions;

    } catch (Exception e) {
      log.info("Fail to fetch RPT for {}: {}", path, e.getMessage());
      return null;
    }
  }
}

ProjectContoller 클래스

Spring Security는 RestController에서 @PreAuthorize 애노테이션을 사용하여 메소드 단위로 접근 권한을 제어할 수 있습니다. 이 애노테이션은 Keycloak에서 발급된 JWT 토큰의 권한 정보(Scope, Role 등)를 기반으로 API 접근을 허용하거나 차단합니다.

이 데모의 마이크로서비스는 다음과 같이 SCOPE기반으로 각 API에 접근 권한이 설정되어 있습니다.

@RestController
@RequestMapping("/api/v2/project")
public class ProjectController {

  @PreAuthorize("hasAuthority('SCOPE_read')")
  @GetMapping
  @Operation(summary = "Get all projects", description = "Retrieve a list of all projects")
  public Page<ProjectDto> getAllProjects(
          @Parameter(description = "Pagination information", example = "{ \"page\": 0, \"size\": 1, \"sort\": [\"name,desc\"] }")
          @PageableDefault(sort = "name", direction = Sort.Direction.DESC, size = 5) final Pageable pageable) {
      log.info("get all projects");
      return projectService.getAllProjects(pageable);
  }
    :
    
  @PreAuthorize("hasAuthority('SCOPE_write')")
  @DeleteMapping("/{id}")
  @Operation(summary = "Delete a project", description = "Delete a project by its ID")
  public void deleteProject(@PathVariable String id) {
      projectService.deleteProject(id);
  }
}    

application.yaml

다음과 같이 이 데모에서 마이크로 서비스의 application.yaml에 Keycloak Client를 설정하였습니다. 여기서 resource-set에는 사용자가 접근하려는 API 자원의 경로(prefix)를 정의합니다.

Keycloak:
  realm-url: ${Keycloak_REALM_URL:https://Keycloak.cnap.dev/realms/cnap}
  client-id: ${Keycloak_CLIENT_ID:myapp}
  client-secret: ${Keycloak_CLIENT_ID:ZVpV2w2D2QwOnDVinLHBX2blEwf8JL60}
  resource-set: > 
    /api/v1/project,
    /api/v2/project

6. API 경로 기반의 권한처리 설계

마지막으로 API 경로(Prefix)를 기반으로 사용자의 접근 권한을 제어하는 구조에 대해 좀 더 살펴보겠습니다. 이 방식을 통해 Keycloak의 리소스 기반 권한 모델과 Spring Security를 연동하여, 실제 요청된 API 경로에 따라 적절한 권한 검사를 수행합니다.

Resource Set 설정
사용자가 접근하려는 API 경로의 Prefix 목록입니다. 이 목록은 반드시 Keycloak Authorization에 Resource Name과 일치해야 합니다.

Keycloak:
  resource-set: > 
    /api/v1/project,
    /api/v2/project

Keycloak Authorization Resource설정
Keycloak에서는 API 경로의 Prefix를 리소스로 등록한 후, 각 리소스에 대해 read, write 등의 Scope를 정의하고, 해당 Scope를 Policy 매핑하여 권한을 설정합니다.

  • Resource: /api/v1/project
  • Resource: /api/v2/project

Spring Security 커스터마이징

  • Prefix 기반 리소스 매칭
    • API 경로를 기준으로 등록된 resource-set 목록 중 Prefix가 일치하는 리소스 이름을 탐색합니다.
    • 리소스 경로가 많아지는 경우에도 빠른 탐색이 가능하도록 Trie 자료구조를 사용하여 성능을 최적화하였습니다.
  • Permission 조회
    • 매칭된 리소스를 기반으로 RptTokenFetcher가 Keycloak에 RPT(Resource Permission Token)을 요청하고 해당 리소스에 대한 Permission 정보를 가져옵니다.
  • AuthenticationToken 생성
    • 조회된 Permission 정보를 바탕으로 JwtAuthenticationConverter가 GrantedAuthority 목록을 생성합니다.
    • 이후 JwtAuthenticationProvider는 GrantedAuthority 정보를 포함한 JwtAuthenticationToken을 생성하여 반환하고 Spring Security는 이를 SecurityContext에 저장하여 인증 처리를 완료합니다.

7. 마무리

이 글에서는 Keycloak을 활용한 클라이언트 인증 및 권한 구성 방법과, 이를 Spring Security와 연동하여 마이크로서비스 환경에서 JWT 기반으로 인증 및 권한을 처리하는 방식을 살펴보았습니다. 실제 프로젝트에서는 일반적으로 인증과 역할(Role) 수준의 접근 제어까지만 적용하는 경우가 많습니다. 세분화된 권한 처리는 구현의 복잡성과 시스템 간 제약으로 인해, 종종 백엔드 서비스 내에서 직접 처리하는 방식이 선택되기도 합니다.

서비스의 보안 요구사항과 규모에 맞춰 인증 및 권한 처리 모델을 신중하게 선택하는 것이 중요합니다. Keycloak과 Spring Security의 기능을 유기적으로 결합함으로써 확장성과 유지보수성이 뛰어난 인증·인가 체계를 구축할 수 있습니다.