[Java/IAM] Spring Boot 환경에서 Keycloak 활용하기 -3 : OIDC 인증 흐름 구현(Service Accounts Roles)
해당 글에서는 Spring Boot 환경에서 Keycloak과의 연동을 통하여 OIDC 인증 흐름 구현(Service Accounts Roles) 하는 방법에 대해 알아봅니다.

💡 [참고] Keycloak 초기 구성에서부터 활용방법에 대해 궁금하시면 아래의 글을 참고하시면 도움이 됩니다.
분류 | 주제 | URL |
Docker | Docker Compose를 이용한 Keycloak 환경 구성 및 실행 방법 | https://adjh54.tistory.com/644 |
환경설정 | Google Cloud Console OAuth 2.0 API 액세스 환경 설정하기 | https://adjh54.tistory.com/657 |
이해하기 | Keycloak 이해하기 -1 : 구성 요소, 인증 처리과정, 주요 기능 | https://adjh54.tistory.com/645 |
이해하기 | Keycloak 이해하기 -2 : SAML/OIDC 프로토콜, 인증 흐름(Authentication flow) 종류 | https://adjh54.tistory.com/646 |
이해하기 | Keycloak 이해하기 -3 : 기본 환경 구성 및 로그인/로그아웃 구현 | https://adjh54.tistory.com/647 |
이해하기 | Keycloak 이해하기 -4 : Keycloak 권한 및 종류 | https://adjh54.tistory.com/655 |
구성하기 | Spring Boot 환경에서 Keycloak 활용하기 -1 : OIDC 인증 흐름 구현(Standard Flow) | https://adjh54.tistory.com/648 |
구성하기 | Spring Boot 환경에서 Keycloak 활용하기 -2 : OIDC 인증 흐름 구현(Direct Access Grants, Implicit Flow) | https://adjh54.tistory.com/649 |
구성하기 | Spring Boot 환경에서 Keycloak 활용하기 -3 : OIDC 인증 흐름 구현(Service Accounts Roles) | https://adjh54.tistory.com/654 |
구성하기 | Spring Boot 환경에서 Keycloak 활용하기 -4 : Identity providers Social 소셜 로그인 구현(Google) | https://adjh54.tistory.com/658 |
Github | Spring Boot Keycloak 관련 Repository | https://github.com/adjh54ir/blog-codes/tree/main/spring-boot-keycloakhttps://github.com/adjh54ir/blog-codes/tree/main/spring-boot-keycloak-sub |
1) Keycloak
💡 Keycloak
- Red Hat에서 개발한 오픈소스 Identity and Access Management(IAM) 솔루션입니다. 현대적인 애플리케이션과 서비스를 위한 인증 및 권한 부여 기능을 제공하는 인증 서버(Authentication Server)의 기능을 수행합니다.
- Keycloack에서는 여러 플랫폼에서 중앙 집중식 인증 서버로 동작을 합니다. 주요한 기능은 서로 다른 도메인에서 실행되는 애플리케이션 간의 SSO를 지원하거나 REST API 기반에 접근제어 토큰에 대한 인증 제공 및 세션 타임아웃, 동시 로그인 제한과 같은 다양한 세션 기능을 담당합니다.

Keycloak
Single-Sign On Users authenticate with Keycloak rather than individual applications. This means that your applications don't have to deal with login forms, authenticating users, and storing users. Once logged-in to Keycloak, users don't have to login again
www.keycloak.org
2) OIDC(OpenID Connect)
💡 OIDC(OpenID Connect)
- OAuth 2.0 프로토콜을 확장하여 만든 사용자 인증을 위한 표준화된 인증(Authentication) 프로토콜입니다.
- 기존 OAuth 2.0은 인가(Authorization)에 중점을 두고 있어 사용자 인증에 대한 표준이 부족했습니다. 이러한 한계를 해결하기 위해 OIDC에서는 OAuth 2.0 위에 '표준화된 인증 계층'을 추가한 프로토콜입니다.

1. OIDC 인증 흐름(OpenID Connect Authentication flow)
💡 OIDC 인증 흐름(OpenID Connect Authentication flow)
- OIDC(OpenID Connect) 인증 프로토콜을 이용하여 구현하는 구체적인 인증 방법들을 의미합니다. 즉, 애플리케이션(Client)을 기준으로 OIDC 프로토콜을 통해서 Keycloak과 통신하고 인증하는 방법들을 의미합니다.
- 이러한 OIDC 인증 흐름은 한 가지 방법으로 인증 과정을 수행할 수 있고, 여러 가지 다양한 방법으로 인증과정을 구현할 수 있습니다.
2. 인증 흐름(Authentication flow) 종류
인증 흐름 종류 | 설명 | 사용처 |
Standard Flow | - OAuth 2.0의 Authorization Code Flow 기반으로 인증을 수행하며, 사용자는 Keycloak 로그인 페이지로 리다이렉트 되어서 인증을 수행하는 인증방식을 의미합니다. | 웹 애플리케이션 |
Direct Access Grants | - 직접 REST API를 통해 자격 증명(아이디/비밀번호)을 담아서 통신하며 별도의 리다이렉션 없이 즉시 토큰을 받을 수 있는 인증방식을 의미합니다. | 신뢰할 수 있는 애플리케이션(모바일 애플리케이션, 백엔드 시스템에서 직접 인증이 필요한 경우) |
Implicit Flow | - 간소화된 인증흐름으로 인가 코드(Auth Code) 없이 직접 액세스 토큰을 받아서 인증을 수행하는 방식을 의미합니다. | 모바일 앱, 단일 페이지 애플리케이션(SPA) |
Service Accounts Roles | - 애플리케이션 시스템 간에 인증 기반의 통신을 위한 방법으로, 클라이언트 자체적으로 API를 호출하여 다른 서비스와의 통신을 할 때 사용하는 인증방식입니다. | 마이크로서비스 간 통신(서버-서버간 통신), 자동화된 프로세스 |
OAuth 2.0 Device Authorization Grant | - 스마트 TV나 IoT 기기와 같이 제한된 입력 기능을 가진 디바이스를 위한 인증 방식입니다. | 스마트 TV 애플리케이션, IoT 디바이스, 게임 콘솔, 프린터 및 스캐너 |
OIDC CIBA Grant | - 클라이언트가 사용자의 직접적인 상호작용 없이 인증을 시작하는 인증 방식입니다.- 사용자가 인증을 요청한 디바이스와 실제 인증을 수행하는 디바이스가 물리적으로 분리된 방식입니다. | 금융 서비스, 공유 디바이스, 스마트홈 시스템 |

3. 인증 플로우 : Service Accounts Roles 구현방법
💡 인증 플로우 : Service Accounts Roles 구현방법
- 서비스 계정(Service Accounts)에 할당할 수 있는 권한과 역할을 정의하고 이를 기반으로 Client Credentials Flow 방식을 통해 실제 토큰을 발급받아 인증하는 방식을 의미합니다.
- 이렇게 발급된 토큰에는 해당 서비스 계정에 부여된 권한 정보 및 역할이 포함되어 있어서, 서비스는 자신에게 허용된 범위 내에서만 리소스에 접근할 수 있습니다.
- 여기서 서비스 계정(Service Accounts)은 사용자 자체가 아닌 애플리케이션이나 서비스가 고유하게 가지고 있는 계정을 의미합니다. 각각의 서비스 계정은 특정 역할(Role)과 권한을 가질 수 있습니다. 또한, 서비스 별로 접근 제어(읽기, 쓰기, 삭제, 관리자 권한 등)를 가질 수 있습니다.
- 예를 들어서, 서비스 계정 A, B가 있고, 서비스 계정 A은 서비스 계정 B로 접근할 수 있다는 권한이 할당되었다고 가정하에 있습니다. 그렇게 되면 서비스 계정 A에서 계정 B로 접근할 때 발급받은 토큰을 통해서 인증하여 접근할 수 있습니다. 이는 토큰 내에 서비스 계정에 대한 권한 정보와 역할이 포함되어서 리소스에 대한 접근이 가능합니다.
- 자동화된 프로세스, 배치 작업, 마이크로서비스 간 통신 등에 활용됩니다.
4. Service Accounts Roles + Client Credentials Flow 방식 처리 과정
💡 Service Accounts Roles 처리과정
- Service Accounts Roles 구현방식을 구현하기 위해서는 Client Credentials Flow를 이용하여서 처리합니다.
1. 클라이언트 등록 및 설정
- Keycloak 관리 콘솔에서 클라이언트를 등록하고 Service Account Roles를 활성화하여 사용합니다.
- Assign role 버튼을 눌러서 필요한 역할(roles)과 권한을 클라이언트에 할당하여 설정합니다.
2. 클라이언트 인증 : Client Credentials Flow 방식
- 클라이언트 ID와 클라이언트 시크릿을 사용하여 인증합니다.
- grant_type=client_credentials 파라미터로 토큰 엔드포인트에 요청합니다.
3. 토큰 발급 : Client Credentials Flow 방식
- Keycloak은 클라이언트 자격 증명을 검증합니다.
- 유효한 경우 액세스 토큰 발급 (할당된 역할과 권한 정보 포함)합니다.
4. API 호출
- 발급받은 액세스 토큰으로 보호된 리소스에 접근합니다.
- 토큰에 포함된 권한 범위 내에서만 API 호출 가능하며 사용자가 아닌 서비스 계정 컨텍스트로 동작
5. Service Accounts Roles 장점
💡 Service Accounts Roles 장점
1. 세분화된 접근 제어
- 각 서비스에 필요한 최소한의 권한만 부여하여 보안성 강화
- 서비스별로 다른 수준의 접근 권한 설정 가능
2. 중앙화된 관리 체계
- 모든 서비스 계정의 권한과 역할을 하나의 중앙 시스템(Keycloak)에서 관리
- 권한 변경이나 역할 조정이 필요할 때 단일 지점에서 신속하게 처리 가능
- 일관된 보안 정책 적용과 감사가 용이함
- 관리 오버헤드 감소 및 운영 효율성 향상
3. 책임 추적성 향상
- 각 서비스 계정의 활동을 개별적으로 모니터링 및 감사 가능
- 문제 발생 시 어떤 서비스에서 문제가 발생했는지 빠르게 파악
4. 유연한 권한 관리
- 서비스의 요구사항 변경 시 해당 서비스 계정의 권한만 수정
- 다른 서비스에 영향을 주지 않고 개별 서비스의 권한 조정 가능
5. 보안 리스크 최소화
- 한 서비스가 침해되어도 다른 서비스에 미치는 영향 최소화
- 제로 트러스트 보안 모델 구현 용이
3) 사전 구성
💡 사전 구성
- Keycloak의 구현 방식을 구현하고자 할 때, 기본적으로 Keyclaok 인증 서버에 대한 구축 및 Realm, Client, User에 대한 구성을 진행합니다. 또한, Client에 대한 설정 및 권한을 부여해야 합니다.
1. Keycloak 인증 서버 구축
💡 Keycloak 인증 서버 구축
- Spring Boot App 내에서 접근이 가능한 Keycloak 인증 서버 구축은 Docker를 기반으로 컨테이너로 구성하였습니다.
- 아래의 글을 참고하시면 이를 확인할 수 있습니다.
[Docker] Docker Compose를 이용한 Keycloak 환경 구성 및 실행 방법
해당 글에서는 Docker Compose를 통해서 Keycloak을 구성하는 방법에 대해 알아봅니다.💡[참고] 이전에 작성한 Docker 관련 글들을 읽으시면 도움이 됩니다.분류설명링크이해하기Docker 환경 설치 및 실
adjh54.tistory.com
2. Keycloak 기본 구성요소 구축
💡 Keycloak 기본 구성요소 구축
- Keycloak 인증서버에 관리자 계정으로 접근하여서 기본적인 Realm, Client, User 구성을 수행합니다. 아래의 글을 참고하시면 이를 확인할 수 있습니다.
[OpenSource] Keycloak 이해하기 -3 : 기본 환경 구성 및 로그인/로그아웃 구현
해당 글에서는 Keycloak의 주요 요소 Realm, Client, User, Group, Role을 구성하고 구성환경에 로그인/로그아웃을 수행하는 방법에 대해 알아봅니다 💡 [참고] Keycloak 초기 구성에서부터 활용방법에 대
adjh54.tistory.com
3. [Keycloak > Realm > Client] Realm내 Clients 구성
💡 [Keycloak > Realm > Client] Realm내 Clients 구성
- 아래와 같이 Realm내에 Client인 spring-boot-app과 spring-boot-app-sub라는 두 개의 클라이언트를 구성하였습니다.
- 해당 과정에서 spring-boot-app에서 접근 토큰(Access Token)을 발급받아서 spring-boot-app-sub라는 클라이언트에게 전달을 합니다.
- spring-boot-app-sub라는 곳에서는 이를 받아서 접근 권한(Role)에 따라서 keycloak에 접근을 수행합니다.

[ 더 알아보기 ]
💡 그냥 client에서 바로 발급받아서 접근해도 되지 않나? 꼭 다른 Client에서 발급받은 토큰으로 Keycloak에 접근을 해야 할까?
- Client가 직접 토큰을 발급받아 접근하는 것도 가능하지만, Service Accounts Roles를 사용하는 것이 더 안전하고 관리하기 쉬운 방법입니다.
- 아래와 같은 이유로 Service Accounts Role을 적용합니다.
1. 권한 분리: 각 서비스마다 필요한 최소한의 권한만 부여할 수 있어 보안성이 향상됩니다.
2. 중앙 관리: Keycloak에서 모든 서비스 계정의 권한을 중앙집중적으로 관리할 수 있습니다.
3. 감시 추적: 각 서비스 계정별로 접근 기록을 추적하고 모니터링하기 용이합니다.
4. 확장성: 새로운 서비스가 추가될 때마다 적절한 권한을 가진 새로운 서비스 계정을 쉽게 생성할 수 있습니다.
4. [Keycloak > Realm > Client] Authentication flow 중 Service accounts roles 활성화
💡 [Keycloak > Realm > Client] Authentication flow 중 Service accounts roles 활성화
- spring-boot-app과 spring-boot-app-sub 클라이언트에 대해서 Authentication flow의 항목 중 Service accounts roles를 활성화하여 저장합니다.

5. [Keycloak > Clients > spring-boot-app > Roles] Client 내에 권한 부여
💡 [Keycloak > Clients > spring-boot-app > Roles] Client 내에 권한 부여
- 접근 토큰(Access Token)을 전달하려는 측인 spring-boot-app이라는 클라이언트에서 Role을 부여합니다.
- 이는 전달하려는 토큰 내에 권한을 담아서 수신하려는 측인 spring-boot-app-sub로 전달을 합니다.
5.1. 역할 생성(Create role)

5.2. 역할 이름 및 설명 지정

5.3. 역할 지정(Assign role)
💡 역할 지정(Assign role)
- 해당 역할지정에 대한 시나리오는 메인이 되는 ‘spring-boot-app’에서는 모든 권한이 부여가 되고 ‘spring-boot-app-sub’에서는 role_query-users, role_view-users 권한을 부여하였습니다.
- 이를 통해, spring-boot-app에서 접근 토큰이 부여가 되었지만 수신하는 측에서는 이 토큰을 통해서 사용자 정보 조회만 가능하도록 구성합니다.
역할 분류 | 역할 명 | 상세 설명 |
조회 권한 : realm-management | role_query-users | 사용자 정보를 검색하고 필터링할 수 있는 권한입니다. 사용자 목록을 조회할 때 검색 조건을 적용하여 특정 사용자를 찾을 수 있습니다. |
조회 권한 : realm-management | role_view-users | 기본 정보를 조회할 수 있는 읽기 전용 권한입니다. 사용자의 상세 정보를 볼 수 있지만 검색이나 필터링 기능은 제한됩니다. |

5.4. 역할 부여
💡 역할 부여
- 아래와 같이 접근 토큰(Access Token)을 생성하는 ‘spring-boot-app’측에서 ‘spring-boot-app-sub’ 클라이언트에 대한 역할을 추가합니다.

💡 생성한 spring-boot-app-sub에 대한 권한을 추가하였습니다.

4) HTTP 전송 테스트
💡 HTTP 전송 테스트
- 구성된 Keycloak 환경에서 토큰을 발급받고 이를 기반으로 토큰을 검증하고 이에 대한 역할을 확인합니다.
엔드포인트 이름 | URL | HTTP Method | 설명 |
토큰 발급 | /realms/{realm-name}/protocol/openid-connect/token | POST | 액세스 토큰, 리프레시 토큰을 발급받는 엔드포인트 |
토큰 검증 | /realms/{realm-name}/protocol/openid-connect/token/introspect | POST | 토큰의 유효성을 검사하는 엔드포인트 |
사용자 조회 | /admin/realms/{realm-name}/users | GET | realm의 사용자 목록 조회 |
1. 토큰 발급
💡 토큰 발급
- 구성한 keycloak realm에 자격증명(client_id, client_secret)을 전달하여서 접근 토큰을 즉시 발급받습니다.
// format
// /realms/{realm-name}/protocol/openid-connect/token
// example
// http://localhost:9001/realms/dev-realm/protocol/openid-connect/token

2. 토큰 검증
💡 토큰 검증
- 위에서 발급받은 토큰을 기반으로 토큰 검증을 수행하면, 반환 값으로 이에 대한 역할을 확인할 수 있습니다.
// format
// /realms/{realm-name}/protocol/openid-connect/token/introspect
// example
// http://localhost:9001/realms/dev-realm/protocol/openid-connect/token/introspect

3. 사용자 조회
💡 사용자 조회
- 발급된 토큰을 기반으로 사용자를 조회하였을 때, 사용자 정보가 출력됨을 확인하였습니다.
// format
// /admin/realms/{realm-name}/users
// example
// http://localhost:9001/admin/realms/dev-realm/users

5) Spring Boot 기반 Service Accounts Roles + Client Credentials Flow 구성
💡 Spring Boot 기반 Service Accounts Roles + Client Credentials Flow 구성
- 해당 방식을 통해서 서비스 간의 안전한 통신이 이루어지며, 각 서비스는 자신에게 할당된 역할과 권한 범위 내에서만 API를 호출할 수 있습니다.
- 서비스 A는 ‘spring-boot-app’ 클라이언트를 의미하고 서비스 B는 ‘spring-boot-app-sub’ 클라이언트를 의미합니다
1. 실제 사용자가 엔드포인트를 서비스 A로 요청을 합니다
2. 해당 엔드포인트를 수신하고, 서비스 A의 ient credentials(클라이언트 ID와 시크릿), grant_type= client_credentials을 사용하여 Keycloak에 토큰을 요청합니다.
3. Keycloak에서는 접근 토큰(Access Token)을 반환해 줍니다.
4. 접근 토큰(Access Token)을 Authorization 헤더에 포함하여 서비스 B의 API를 호출합니다.
5. 서비스 B에서는 Keycloak에 전달하여 토큰의 유효성을 검증을 요청합니다.
6. Keycloak에서는 전달받은 접근 토큰을 기반으로 토큰 유효성 검증 결과를 반환해 줍니다.
7. 서비스 B에서는 해당 유효성 검증을 통과한 접근 토큰(Access Token)을 기반으로 Keycloak에 사용자 정보를 요청합니다
- 해당 사용자 정보에 대한 권한은 토큰 내에 존재합니다
8. Keycloak은 사용자 정보를 반환해 줍니다.

1. 구성 환경 확인
💡 구성 환경 확인
- 아래와 같은 서비스 A, B라는 명칭을 부여하고 각각 아래와 같은 클라이언트 아이디를 가집니다.
- 서비스 A에서 발급한 접근 토큰을 기반으로 B에게 전달하여 B에서는 할당된 역할과 권한이 포함된 접근 토큰을 통해 Keycloak에 접근하여서 데이터를 조회해 오는 처리 과정을 구성하였습니다.
명칭 | Client ID | 프로젝트 명 | 도메인 |
Keyclaok | - | - | http://localhost:9001 |
Realm | dev-realm | - | |
서비스 A | spring-boot-app | spring-boot-keycloak | http://localhost:8080 |
서비스 B | spring-boot-app-sub | spring-boot-keycloak-sub | http://localhost:8081 |

2. 서비스 A 접근 토큰 발급 : grant_type = client_credentials
💡 서비스 A 접근 토큰 발급 : grant_type = client_credentials
- 클라이언트 자격증명(client_id, client_secret)으로 토큰을 발급받는 방식을 이용하여서 서비스 A에서 토큰을 발급받습니다.

2.1. AccessTokenReqDto
💡 AccessTokenReqDto
- grant_type = client_credentials을 통해서 토큰 정보를 요청할 때, 필요로 하는 객체를 정의하였습니다.
package com.blog.springbootkeycloak.dto;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* Access Token 발급 요청 객체
*
* @author : jonghoon
* @fileName : AccessTokenReqDto
* @since : 25. 1. 25.
*/
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AccessTokenReqDto {
private String code;
private String grant_type;
private String client_id;
private String client_secret;
private String username;
private String password;
private String redirect_uri;
@Builder
public AccessTokenReqDto(String code, String grant_type, String client_id, String client_secret, String username, String password, String redirect_uri) {
this.code = code;
this.grant_type = grant_type;
this.client_id = client_id;
this.client_secret = client_secret;
this.username = username;
this.password = password;
this.redirect_uri = redirect_uri;
}
}
2.2. AccessTokenResDto
💡 AccessTokenResDto
- grant_type = client_credentials을 통해서 토큰 정보를 요청하여 응답을 받는 객체입니다.
package com.blog.springbootkeycloak.dto;
import lombok.*;
/**
* Access Token 발급 응답 객체
*
* @author : jonghoon
* @fileName : TokenResponseDto
* @since : 25. 1. 28.
*/
@ToString
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AccessTokenResDto {
private String access_token;
private int expires_in;
private int refresh_expires_in;
private String refresh_token;
private String token_type;
private int not_before_policy;
private String session_state;
private String scope;
@Builder
public AccessTokenResDto(String access_token, int expires_in, int refresh_expires_in, String refresh_token, String token_type, int not_before_policy, String session_state, String scope) {
this.access_token = access_token;
this.expires_in = expires_in;
this.refresh_expires_in = refresh_expires_in;
this.refresh_token = refresh_token;
this.token_type = token_type;
this.not_before_policy = not_before_policy;
this.session_state = session_state;
this.scope = scope;
}
}
2.3. KeycloakService
💡 KeycloakService
- openid-connect/token 엔드포인트를 기반으로 Keycloak과 통신하여서 접근 토큰을 발급받습니다. 이 중 grant_type으로 ‘client_credentials’를 통해서 접근 토큰을 발급받습니다.
package com.blog.springbootkeycloak.service;
import com.blog.springbootkeycloak.dto.AccessTokenResDto;
import com.blog.springbootkeycloak.dto.AccessTokenReqDto;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
/**
* OpenFeign 통해서 Keycloak 서비스 통신을 수행합니다.
*
* @author : jonghoon
* @fileName : KeycloakService
* @since : 11/23/24
*/
@FeignClient(
name = "keycloak-auth-service",
url = "<http://localhost:9001/realms/dev-realm//protocol/openid-connect>"
)
@Service
public interface KeycloakService {
/**
* 접근 토큰(Access Token) 발급
*
* @param accessTokenReqDto 전송 객체 값 (form 데이터 형태로 전송)
* @return 토큰 값 반환
*/
@PostMapping(value = "/token", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
AccessTokenResDto getAccessToken(@ModelAttribute AccessTokenReqDto accessTokenReqDto);
}
2.4. AuthFlowController
💡 AuthFlowController
- [POST] /api/v1/keycloak/authFlow/clientCredentials 엔드포인트를 호출하여 getAccessToken() 메서드를 호출하여서 접근 토큰을 발급받도록 하였습니다.
package com.blog.springbootkeycloak.controller;
import com.blog.springbootkeycloak.dto.AccessTokenReqDto;
import com.blog.springbootkeycloak.dto.AccessTokenResDto;
import com.blog.springbootkeycloak.dto.AuthCodeDto;
import com.blog.springbootkeycloak.service.ClientCredentialService;
import com.blog.springbootkeycloak.service.KeycloakService;
import com.blog.springbootkeycloak.service.OAuthService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* OIDC 인증 플로우 구성
*
* @author : jonghoon
* @fileName : AuthFlowController
* @since : 25. 1. 28.
*/
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/keycloak/authFlow")
public class AuthFlowController {
private final KeycloakService keycloakService;
/**
* Client Credentials : 토큰 발급
*
* @return
*/
@PostMapping("/clientCredentials")
public ResponseEntity<Object> callProtectedApi(@RequestBody AccessTokenReqDto accessTokenReqDto) {
try {
AccessTokenResDto resultToken = keycloakService.getAccessToken(accessTokenReqDto);
return new ResponseEntity<>(resultToken, HttpStatus.OK);
} catch (Exception e) {
log.error("Token request failed", e);
return new ResponseEntity<>(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
}
}
}
3. 서비스 A 접근 토큰 발급 : grant_type = client_credentials 결과 확인
💡 서비스 A 접근 토큰 발급 : grant_type = client_credentials 결과 확인
- 아래와 같이 Access Token이 출력되었습니다.

4. 서비스 A 토큰을 서비스 B로 전달
💡 서비스 A 토큰을 서비스 B로 전달
- spring-boot-app Client에서 spring-boot-app-sub로 접근 토큰을 전달하고 전달된 토큰에 대해 Keycloak에 토큰의 유효성을 체크합니다.

4.1. [서비스 A] SubApiCallService
💡 [서비스 A] SubApiCallService
- 서비스 B의 엔드포인트를 @RequestHeader("Authorization")로 호출하여 성공여부를 boolean으로 반환받는 서비스를 구성하였습니다.
package com.blog.springbootkeycloak.service;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
/**
* spring-boot-app-sub로 데이터를 전달합니다.
*
* @author : jonghoon
* @fileName : SubApiCallService
* @since : 25. 1. 30.
*/
@Service
@FeignClient(name = "keycloak-sub-call", url = "<http://localhost:8081/api/v1/keycloak/receive>")
public interface SubApiCallService {
/**
* spring-boot-app에서 발급된 토큰을 spring-boot-sub로 전달합니다.
*
* @param bearerToken
* @return
*/
@GetMapping("/token")
boolean sendAccessTokenToSubApi(@RequestHeader("Authorization") String bearerToken);
}
4.2. [서비스 A] AuthFlowController
💡 [서비스 A] AuthFlowController
- 토큰을 발급받는 기존 서비스에서 받음과 동시에 전달을 하는 서비스를 호출하여 토큰을 전달하고 반환값을 받습니다.
package com.blog.springbootkeycloak.controller;
import com.blog.springbootkeycloak.dto.AccessTokenReqDto;
import com.blog.springbootkeycloak.dto.AccessTokenResDto;
import com.blog.springbootkeycloak.dto.AuthCodeDto;
import com.blog.springbootkeycloak.service.KeycloakService;
import com.blog.springbootkeycloak.service.SubApiCallService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* OIDC 인증 플로우 구성
*
* @author : jonghoon
* @fileName : AuthFlowController
* @since : 25. 1. 28.
*/
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/keycloak/authFlow")
public class AuthFlowController {
private final KeycloakService keycloakService;
private final SubApiCallService subApiCallService;
/**
* Client Credentials : 토큰 발급 및 외부 서비스 토큰 전달
*
* @return
*/
@PostMapping("/clientCredentials")
public ResponseEntity<Object> callProtectedApi(@RequestBody AccessTokenReqDto accessTokenReqDto) {
try {
AccessTokenResDto resultToken = keycloakService.getAccessToken(accessTokenReqDto); // Keycloak 통신 : 접근 토큰 발급
String accessToken = resultToken.getAccess_token();
// 토큰 생성 실패 시
if (accessToken.isEmpty()) {
log.error("Token is empty");
return new ResponseEntity<>("", HttpStatus.OK);
}
// 토큰 생성 성공 => 전달
boolean isReceive = subApiCallService.sendAccessTokenToSubApi(accessToken); // 외부 서비스 통신 : 접근 토큰 전달
log.debug("성공적으로 전달되었는가 ? : {}", isReceive);
return new ResponseEntity<>(resultToken, HttpStatus.OK);
} catch (Exception e) {
log.error("Token request failed", e);
return new ResponseEntity<>(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
}
}
}
4.3. [서비스 B] TokenIntrospectionReqDto
💡 [서비스 B] TokenIntrospectionReqDto
- 토큰 검증을 위한 요청 데이터를 DTO로 구성하였습니다.
package com.adjh.springbootkeycloaksub.dto;
import lombok.*;
/**
* 토큰 유효성 검증을 위한 요청 객체
*
* @author : leejonghoon
* @fileName : TokenIntrospectionReqDto
* @since : 2025. 2. 3.
*/
@Getter
@ToString
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class TokenIntrospectionReqDto {
private String token;
private String client_id;
private String client_secret;
@Builder
public TokenIntrospectionReqDto(String token, String client_id, String client_secret) {
this.token = token;
this.client_id = client_id;
this.client_secret = client_secret;
}
}
4.4. [서비스 B] TokenIntrospectionResDto
💡 [서비스 B] TokenIntrospectionResDto
- 토큰 유효성 검증 수행 이후 응답 객체를 DTO로 구성하였습니다.
package com.adjh.springbootkeycloaksub.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.*;
import java.util.List;
import java.util.Map;
/**
* 토큰 유효성 검증 수행 이후 응답 객체
*
* @author : leejonghoon
* @fileName : TokenIntrospectionResDto
* @since : 2025. 2. 3.
*/
@Getter
@ToString
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class TokenIntrospectionResDto {
private Long exp; // 토큰 만료 시간
private Long iat; // 토큰 발급 시간
private String jti; // JWT ID
private String iss; // 토큰 발급자
private String[] aud; // 토큰 대상
private String sub; // 사용자 ID
private String typ; // 토큰 타입
private String azp; // Authorized party
private String sid; // 세션 ID
private String acr; // Authentication Context Class Reference
@JsonProperty("allowed-origins")
private List<String> allowedOrigins; // 허용된 출처
@Getter
@Setter
@ToString
@NoArgsConstructor
public static class Access {
private String[] roles;
}
@JsonProperty("realm_access")
private Access realmAccess; // Realm 권한
@JsonProperty("resource_access")
private Map<String, Access> resourceAccess; // 리소스 접근 권한
private String scope; // 권한 범위
@JsonProperty("email_verified")
private Boolean emailVerified; // 이메일 인증 여부
private String name; // 전체 이름
@JsonProperty("preferred_username")
private String preferredUsername; // 선호하는 사용자명
private String givenName; // 이름
private String familyName; // 성
private String email; // 이메일
@JsonProperty("client_id")
private String clientId; // 클라이언트 ID
private String username; // 사용자명
@JsonProperty("token_type")
private String tokenType; // 토큰 타입
private Boolean active; // 토큰 활성 상태
}
4.5. [서비스 B] ReceiveController
💡 [서비스 B] ReceiveController
- 전달받은 측에서는 토큰을 확인하고 역할을 체크합니다.
package com.adjh.springbootkeycloaksub.controller;
import com.adjh.springbootkeycloaksub.config.properties.KeycloakProperties;
import com.adjh.springbootkeycloaksub.dto.TokenIntrospectionReqDto;
import com.adjh.springbootkeycloaksub.dto.TokenIntrospectionResDto;
import com.adjh.springbootkeycloaksub.service.KeycloakService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* Please explain the class!!
*
* @author : leejonghoon
* @fileName : ReceiveController
* @since : 2025. 1. 31.
*/
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/keycloak/receive")
public class ReceiveController {
private final KeycloakService keycloakService;
private final KeycloakProperties properties;
/**
* Direct Access Grants Flow : 토큰을 즉시 요청하는 방법
*
* @param bearerToken 전송 객체
* @return 토큰 값 반환
*/
@GetMapping("/token")
public boolean receiveToken(@RequestHeader("Authorization") String bearerToken) {
log.error("파라미터로 전달받은 토큰 :: {} ", bearerToken);
boolean isReceive = false;
// [Valid] Bearer 토큰여부 확인
if (!bearerToken.isEmpty()) {
// 1. 토큰 유효성 검사
// Keycloak 서버를 통해 토큰 검증
TokenIntrospectionReqDto tokenIntrospectionReqDto = TokenIntrospectionReqDto.builder()
.token(bearerToken)
.client_id(properties.getSpringBootApp().getResource())
.client_secret(properties.getSpringBootApp().getCredentials().getSecret())
.build();
TokenIntrospectionResDto tokenIntrospectionResDto = keycloakService.tokenIntrospect(tokenIntrospectionReqDto);
// 2. 토큰 활성화 여부 확인
if (tokenIntrospectionResDto.getActive()) {
isReceive = true;
log.debug("토큰 유효성 검증 및 권한 확인 : {}", tokenIntrospectionResDto.toString());
}
// 4. 요청 처리
isReceive = true;
}
return isReceive;
}
}
4.6. [서비스 B] KeycloakService
💡 [서비스 B] KeycloakService
- 해당 서비스에서는 Keycloak과 통신하여 토큰의 유효성 검증 및 역할을 체크합니다.
package com.adjh.springbootkeycloaksub.service;
import com.adjh.springbootkeycloaksub.dto.TokenIntrospectionReqDto;
import com.adjh.springbootkeycloaksub.dto.TokenIntrospectionResDto;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
/**
* Please explain the class!!
*
* @author : leejonghoon
* @fileName : KeycloakService
* @since : 2025. 2. 3.
*/
@FeignClient(
name = "keycloak-auth-service",
url = "<http://localhost:9001/realms/dev-realm/protocol/openid-connect>"
)
@Service
public interface KeycloakService {
/**
* 토큰의 유효성을 검증합니다.
*
* @param tokenIntrospectionReqDto
* @return
*/
@PostMapping(value = "/token/introspect", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
TokenIntrospectionResDto tokenIntrospect(@ModelAttribute TokenIntrospectionReqDto tokenIntrospectionReqDto);
}
4.7. 결과 확인
💡 결과 확인
- 아래와 같이 서비스 A 측에서는 처리 결과에 대한 값을 전달받았습니다.

💡 아래와 같이 서비스 B 측에서는 처리된 데이터가 반환되었습니다.

5. 서비스 B 토큰 기반 사용자 정보 조회
💡 서비스 B 토큰 기반 사용자 정보 조회
- 이전 단계에서 토큰의 유효성을 확인하여서, 이 토큰을 기반으로 사용자 정보를 조회하고, 요청한 사용자에게 리소스를 전달해 주는 최종 과정입니다.

5.1. [서비스 B] KeycloakUserDto
💡 [서비스 B] KeycloakUserDto
- Keycloak 사용자 데이터를 DTO로 전달받습니다.
package com.adjh.springbootkeycloaksub.dto;
import lombok.*;
import java.util.List;
/**
* Keycloak 사용자 정보 조회
*
* @author : leejonghoon
* @fileName : KeycloakUserDto
* @since : 2025. 2. 5.
*/
@Getter
@ToString
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class KeycloakUserDto {
private String id;
private String username;
private String firstName;
private String lastName;
private String email;
private boolean emailVerified;
private long createdTimestamp;
private boolean enabled;
private boolean totp;
private List<String> disableableCredentialTypes;
private List<String> requiredActions;
private int notBefore;
private Access access;
@Getter
@ToString
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public static class Access {
private boolean manageGroupMembership;
private boolean view;
private boolean mapRoles;
private boolean impersonate;
private boolean manage;
}
@Builder
public KeycloakUserDto(String id, String username, String firstName, String lastName, String email, boolean emailVerified, long createdTimestamp, boolean enabled, boolean totp, List<String> disableableCredentialTypes, List<String> requiredActions, int notBefore, Access access) {
this.id = id;
this.username = username;
this.firstName = firstName;
this.lastName = lastName;
this.email = email;
this.emailVerified = emailVerified;
this.createdTimestamp = createdTimestamp;
this.enabled = enabled;
this.totp = totp;
this.disableableCredentialTypes = disableableCredentialTypes;
this.requiredActions = requiredActions;
this.notBefore = notBefore;
this.access = access;
}
}
5.2. [서비스 B] KeycloakAdminService
💡 [서비스B] KeycloakAdminService
- Keycloak Admin REST API와 통신하기 위한 Feign Client 인터페이스입니다.
- 구성한 http://localhost:9001/admin/realms/dev-realm/users 와 통신을 수행하여서 사용자 정보를 반환해 옵니다.
package com.adjh.springbootkeycloaksub.service;
import com.adjh.springbootkeycloaksub.dto.KeycloakUserDto;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import java.util.List;
/**
* Please explain the class!!
*
* @author : leejonghoon
* @fileName : KeycloakAdminService
* @since : 2025. 2. 5.
*/
@FeignClient(
name = "keycloak-auth-admin-service",
url = "<http://localhost:9001/admin/realms/dev-realm>"
)
@Service
public interface KeycloakAdminService {
/**
* 사용자 리스트를 조회합니다.
*
* @param bearerToken
* @return
*/
@GetMapping(value = "/users")
List<KeycloakUserDto> getUserInfo(@RequestHeader("Authorization") String bearerToken);
}
5.3. [서비스 B] ReceiveController
💡 [서비스B] ReceiveController
- 서비스 A에서 접근 토큰(Access Token)을 전달받아서, 해당 Controller에서는 토큰에 대한 유효성을 검증하고 검증이 성공되면 이를 기반으로 사용자 정보를 조회합니다.
- 최종적으로 서비스 A에게 사용자 정보를 리턴해주는 구조로 구성되어 있습니다.
package com.adjh.springbootkeycloaksub.controller;
import com.adjh.springbootkeycloaksub.config.properties.KeycloakProperties;
import com.adjh.springbootkeycloaksub.dto.KeycloakUserDto;
import com.adjh.springbootkeycloaksub.dto.TokenIntrospectionReqDto;
import com.adjh.springbootkeycloaksub.dto.TokenIntrospectionResDto;
import com.adjh.springbootkeycloaksub.service.KeycloakAdminService;
import com.adjh.springbootkeycloaksub.service.KeycloakService;
import lombok.extern.slf4j.Slf4j;
import org.keycloak.admin.client.Keycloak;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
/**
* Please explain the class!!
*
* @author : leejonghoon
* @fileName : ReceiveController
* @since : 2025. 1. 31.
*/
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/keycloak/receive")
public class ReceiveController {
private final KeycloakService keycloakService;
private final KeycloakAdminService keycloakAdminService;
private final KeycloakProperties properties;
private final Keycloak keycloak;
/**
* Direct Access Grants Flow : 토큰을 즉시 요청하는 방법
*
* @param bearerToken 전송 객체
* @return 토큰 값 반환
*/
@GetMapping("/token")
public List<KeycloakUserDto> receiveToken(@RequestHeader("Authorization") String bearerToken) {
log.debug("파라미터로 전달받은 토큰 :: {} ", bearerToken);
List<KeycloakUserDto> resultList = new ArrayList<>();
// [Valid] Bearer 토큰여부 확인
if (!bearerToken.isEmpty()) {
// 1. [Keycloak] 토큰 유효성 검사
TokenIntrospectionReqDto tokenIntrospectionReqDto = TokenIntrospectionReqDto.builder()
.token(bearerToken)
.client_id(properties.getSpringBootApp().getResource())
.client_secret(properties.getSpringBootApp().getCredentials().getSecret())
.build();
TokenIntrospectionResDto validTokenDto = keycloakService.tokenIntrospect(tokenIntrospectionReqDto); // Keycloak : 토큰 유효성 검증
log.debug("토큰의 유효성 검증 :: {}", validTokenDto);
// 2. 토큰 활성화 여부 확인
if (validTokenDto.getActive()) {
// 3. [Keycloak] 사용자 조회
resultList = keycloakAdminService.getUserInfo("Bearer " + bearerToken);
log.debug("사용자 정보 조회 :: {}", resultList);
}
}
return resultList;
}
}
5.4. 결과 확인
💡 결과 확인
- 처음 서비스 A에서 호출을 하여서 서비스 B에서 비즈니스 로직을 수행시킨 다음, 결과 값을 반환해 줍니다.
💡 서비스 B에서 이를 수행한 콘솔입니다.
1. 서비스 A에서 전달받은 접근토큰(Access Token)을 콘솔에 출력하였습니다.
2. 전달받은 접근토큰을 유효성 검증하여 결과값을 콘솔에 출력하였습니다.
3. 유효성 검증이 통과한 토큰을 통해 사용자 조회를 수행하여 콘솔에 출력하였습니다.

💡 최종적으로 권한을 부여한 B에 대해서 사용자 정보를 조회해 옴을 확인하였습니다.

오늘도 감사합니다 😀
