Spring Boot 3으로 배우는 OAuth 2.0과 OpenID Connect
1. 개요
OAuth 2.0과 OpenID Connect(OIDC)는 함께 언급되곤 해서 많은 사람들이 둘을 같은 것으로 생각합니다. 하지만 실제로는 두 가지 다른 문제를 해결합니다. OAuth 2.0은 승인(애플리케이션이 사용자를 대신해 API를 호출하도록 허용)을 담당하고, OIDC는 인증(사용자가 누구인지 파악)을 담당합니다.
이 글에서는 Authorization Code + PKCE 흐름을 이해하게 됩니다. 그 다음 두 개의 Spring Boot 3 애플리케이션을 만듭니다: OIDC로 로그인하는 클라이언트와 JWT 형태의 access token을 검증하는 리소스 서버입니다. 글 마지막에는 리소스 서버의 테스트와 자주 만나는 오류 목록이 있습니다.
2. OAuth 2.0과 OpenID Connect의 차이점
2.1. OAuth 2.0의 역할
OAuth 2.0은 네 가지 역할을 정의합니다:
- Resource Owner: 사용자, 데이터의 소유자(예: 주문이 있는 고객).
- Client: 데이터에 접근하려는 애플리케이션(예: 웹
shop-web). - Authorization Server: 사용자가 로그인하고 권한을 부여하고 토큰을 발급하는 곳. 예: Keycloak, Okta, Auth0.
- Resource Server: 데이터를 포함하는 API(예:
order-api).
OAuth 2.0의 결과는 access token입니다. 이 토큰은 리소스 서버를 위한 것이지, 클라이언트를 위한 것이 아닙니다. 클라이언트는 단지 토큰을 가지고 전송할 뿐입니다. 클라이언트는 토큰을 읽어서 사용자가 누구인지 추론해서는 안 됩니다.
2.2. OIDC가 추가하는 것
OIDC는 OAuth 2.0 위에 있는 한 계층입니다. 클라이언트가 openid 범위를 요청하면, authorization server는 추가로 ID token을 반환합니다. 이것은 항상 JWT이며, 서명되어 있고, 인증 정보를 포함합니다:
{
"iss": "http://localhost:8180/realms/shop",
"sub": "customer-42",
"aud": "shop-web",
"exp": 1760353200,
"iat": 1760352900,
"nonce": "n-0S6_WzA2Mj",
"email": "lan@example.com",
"name": "Nguyễn Thị Lan"
}
중요한 claim은 다음과 같습니다:
iss: 토큰을 발급한 곳.sub: 사용자의 고유하고 안정적인 식별자.aud: 이 토큰이 어느 클라이언트를 위한 것인지.nonce: 재전송 공격 방지.
다음과 같이 요약할 수 있습니다:
Access token -> API에 전송(리소스 서버) -> "무엇을 할 수 있는가"
ID token -> 클라이언트가 로그인할 때 읽음 -> "사용자가 누구인가"
3. Authorization Code + PKCE 흐름
웹 애플리케이션, SPA 및 모바일의 경우 Authorization Code 흐름에 PKCE(Proof Key for Code Exchange)를 함께 사용해야 합니다. Implicit flow와 Password grant는 더 이상 권장되지 않습니다.
1. 사용자가 shop-web에서 "로그인" 클릭
2. shop-web이 무작위 code_verifier를 생성하고, code_challenge = SHA256(code_verifier) 계산
3. 브라우저를 다음으로 리다이렉트:
/authorize?response_type=code&client_id=shop-web&scope=openid profile email
&redirect_uri=...&state=xyz&nonce=abc
&code_challenge=...&code_challenge_method=S256
4. 사용자가 Keycloak에 로그인하고, Keycloak이 다시 리다이렉트:
/login/oauth2/code/shop?code=SplxlOBe&state=xyz
5. shop-web이 (back-channel을 통해) POST /token을 code + code_verifier + client_secret으로 호출
6. Keycloak이 반환: access_token, id_token, refresh_token
7. shop-web이 id_token을 검증(서명, iss, aud, exp, nonce) -> 세션 생성
8. shop-web이 order-api를 호출: Authorization: Bearer <access_token> 헤더 포함
각 매개변수는 특정 공격을 방어합니다:
state는 콜백 단계에서 CSRF를 방지합니다.nonce는 ID token을 올바른 로그인 세션과 연결합니다.- PKCE는
code를 탈취한 공격자도code_verifier가 없으면 토큰으로 교환할 수 없도록 보장합니다.
좋은 소식은 Spring Security가 위의 거의 모든 단계를 대신 처리해 준다는 것입니다.
4. OIDC로 로그인하는 클라이언트 구축
4.1. Dependency와 설정
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
Keycloak이 포트 8180에서 실행 중이고 realm shop과 클라이언트 shop-web이 있다고 가정합니다:
spring:
security:
oauth2:
client:
registration:
shop:
provider: keycloak
client-id: shop-web
client-secret: ${SHOP_CLIENT_SECRET}
authorization-grant-type: authorization_code
scope: openid, profile, email, orders.read
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
keycloak:
issuer-uri: http://localhost:8180/realms/shop
issuer-uri만 선언하면 됩니다. Spring은 /.well-known/openid-configuration을 읽어 authorize, token, jwks, end_session 엔드포인트를 자동으로 검색합니다. openid 범위가 OAuth 2.0 흐름을 OIDC 로그인으로 변환합니다.
4.2. 보안 설정
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http,
ClientRegistrationRepository registrations) throws Exception {
var resolver = new DefaultOAuth2AuthorizationRequestResolver(
registrations, "/oauth2/authorization");
resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce());
var logoutHandler = new OidcClientInitiatedLogoutSuccessHandler(registrations);
logoutHandler.setPostLogoutRedirectUri("{baseUrl}");
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/").permitAll()
.anyRequest().authenticated())
.oauth2Login(login -> login
.authorizationEndpoint(ep -> ep.authorizationRequestResolver(resolver)))
.logout(logout -> logout.logoutSuccessHandler(logoutHandler));
return http.build();
}
}
이 설정은 세 가지 일을 합니다:
oauth2Login은 섹션 3의 전체 흐름(리다이렉트, 콜백, 코드 교환, ID token 검증)을 활성화합니다.withPkce()는 secret이 있는 confidential client에도code_challenge를 추가합니다. public client는 Spring이 이미 PKCE를 활성화합니다.OidcClientInitiatedLogoutSuccessHandler는 Keycloak 쪽에서도 로그아웃합니다(RP-initiated logout). 없으면 사용자가 다시 “로그인”을 누르면 비밀번호 입력 없이 바로 들어갑니다.
4.3. 사용자 정보 읽기 및 API 호출
@RestController
public class AccountController {
private final RestClient orderApi = RestClient.create("http://localhost:8081");
@GetMapping("/me")
public Map<String, Object> me(@AuthenticationPrincipal OidcUser user) {
return Map.of(
"subject", user.getSubject(),
"email", Objects.requireNonNullElse(user.getEmail(), "(없음)"),
"name", Objects.requireNonNullElse(user.getFullName(), "(없음)"));
}
@GetMapping("/my-orders")
public String myOrders(@RegisteredOAuth2AuthorizedClient("shop") OAuth2AuthorizedClient client) {
return orderApi.get()
.uri("/api/orders")
.headers(h -> h.setBearerAuth(client.getAccessToken().getTokenValue()))
.retrieve()
.body(String.class);
}
}
두 엔드포인트는 두 가지 다른 토큰 유형을 사용합니다:
OidcUser는 ID token(그리고 UserInfo가 있으면)에서 생성됩니다. 사용자가 누구인지 알기 위한 올바른 방법입니다.@RegisteredOAuth2AuthorizedClient는 registrationshop의 access token을 가져옵니다. 토큰이 만료되었고 refresh token이 있으면, Spring이 반환 전에 자동으로 갱신합니다.
로그인 후 /me를 호출하면 다음을 반환합니다:
{"subject":"customer-42","email":"lan@example.com","name":"Nguyễn Thị Lan"}
참고:
Map.of는 값이null이면NullPointerException을 던집니다.
5. JWT를 검증하는 리소스 서버 구축
5.1. Dependency와 설정
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
server:
port: 8081
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8180/realms/shop
audiences: order-api
이 설정을 사용하면 Spring은 각 요청을 다음 단계에 따라 검증합니다:
- issuer의
jwks_uri에서 공개 키를 가져옵니다. - 서명과
exp/nbf를 검증합니다. iss가issuer-uri와 일치하는지 확인합니다.aud에order-api가 포함되어 있는지 확인합니다.
audience를 검증하지 않는 것은 매우 흔한 오류입니다. 그러면 API가 같은 realm의 다른 서비스를 위해 발급된 토큰까지 수락하게 됩니다.
5.2. Scope에 따른 권한 분리
public record Order(String id, String customerId, BigDecimal total) {}
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {
@Bean
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("SCOPE_orders.read")
.anyRequest().authenticated())
.oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults()))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(AbstractHttpConfigurer::disable);
return http.build();
}
}
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping
public List<Order> myOrders(@AuthenticationPrincipal Jwt jwt) {
return orderService.findByCustomer(jwt.getSubject());
}
}
기본적으로 Spring은 scope(또는 scp) claim을 SCOPE_ 접두사가 있는 authority로 변환합니다. 따라서 scope orders.read는 SCOPE_orders.read가 됩니다.
CSRF를 왜 비활성화할까요? 이 API는 stateless이고 Authorization 헤더를 통해서만 토큰을 받으며 쿠키를 사용하지 않습니다. 따라서 CSRF를 비활성화하는 것이 합리적입니다.
Controller는 query string에서 customerId를 받지 않고 토큰에서 sub를 가져옵니다. 이렇게 하면 사용자가 다른 사람의 주문을 볼 수 없습니다.
다양한 요청에 대한 결과:
토큰 없음 -> 401 Unauthorized
유효한 토큰이지만 scope 부족 -> 403 Forbidden
유효한 토큰, orders.read 있음 -> 200 OK + 사용자의 주문 목록
참고: Keycloak은 role을
realm_access.roles에 두며,scope에 없습니다. role에 따라 권한을 분리하려면 해당 claim을GrantedAuthority에 매핑하는 별도의JwtAuthenticationConverter를 선언하세요.
6. 리소스 서버 테스트
테스트하려고 실제 Keycloak이 필요하지 않습니다. spring-security-test 라이브러리에는 인증된 토큰을 시뮬레이션하는 post-processor jwt()가 있습니다.
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
@Import(ResourceServerConfig.class)
class OrderControllerTest {
@Autowired
MockMvcTester mvc;
@MockitoBean
OrderService orderService;
@MockitoBean
JwtDecoder jwtDecoder; // context 시작 시 실제 issuer 호출 피하기
@Test
void returnsOrdersOfCurrentCustomer() {
given(orderService.findByCustomer("customer-42"))
.willReturn(List.of(new Order("A-1001", "customer-42", new BigDecimal("250000"))));
assertThat(mvc.get().uri("/api/orders")
.with(jwt().jwt(j -> j.subject("customer-42"))
.authorities(new SimpleGrantedAuthority("SCOPE_orders.read"))))
.hasStatusOk()
.bodyJson().extractingPath("$[0].id").isEqualTo("A-1001");
}
@Test
void rejectsTokenWithoutScope() {
assertThat(mvc.get().uri("/api/orders").with(jwt()))
.hasStatus(HttpStatus.FORBIDDEN);
}
@Test
void rejectsRequestWithoutToken() {
assertThat(mvc.get().uri("/api/orders"))
.hasStatus(HttpStatus.UNAUTHORIZED);
}
}
이 세 가지 테스트는 섹션 5.2의 세 가지 분기를 포함합니다:
jwt()는 디코딩 단계를 건너뜁니다.JwtAuthenticationToken을SecurityContext에 직접 설정하므로 테스트가 빠르고 독립적입니다.authorities(...)을 명시적으로 전달해야 합니다.jwt()는 기본적으로SCOPE_read만 있으므로 두 번째 테스트는 403을 받습니다.MockMvcTester(Spring Boot 3.4+)는 response에서 AssertJ를 사용해 assertion을 작성할 수 있게 합니다.
7. 흔한 오류와 사용하지 말아야 할 때
API를 호출할 때 ID token을 사용합니다. ID token의 aud는 클라이언트이지, API가 아닙니다. 올바르게 설정된 리소스 서버는 이를 거부합니다. 항상 access token을 보내세요.
로그인할 때 access token을 사용합니다. 순수한 OAuth 2.0의 경우 access token은 opaque 문자열이고 사용자에 대한 정보를 포함하지 않을 수 있습니다. 클라이언트가 access token JWT를 직접 파싱해서 신원을 알아내려고 하면, 그것은 보장되지 않은 형식에 의존하는 것입니다. ID token이나 UserInfo 엔드포인트를 사용하세요.
SPA에서 localStorage에 토큰을 저장합니다. 하나의 XSS 취약점이면 충분히 토큰 전체가 노출됩니다. 더 안전한 패턴은 BFF(Backend for Frontend)입니다. 섹션 4의 Spring 애플리케이션은 서버 쪽에 토큰을 유지하고, 브라우저는 HttpOnly 세션 쿠키만 가집니다.
aud를 확인하지 않거나 access token의 수명을 너무 길게 합니다. JWT는 만료되기 전에 취소할 수 없습니다. access token의 수명을 짧게 유지하고(5-15분) refresh token을 사용해 갱신하세요.
issuer-uri이 일치하지 않습니다. Keycloak이 iss를 http://keycloak:8080/...(Docker의 호스트명)으로 반환하지만 localhost:8180으로 설정하면, 모든 토큰이 invalid_token 오류로 거부됩니다. iss 값은 정확히 일치해야 합니다.
참고: 항상 OAuth 2.0이 필요한 것은 아닙니다. 폼 로그인이 있는 단일 monolith 애플리케이션이고 제3자가 API를 호출할 필요가 없다면, Spring Security의 일반 세션이 더 간단하고 안전합니다. 사용자가 없는 기계 간 통신의 경우
client_credentialsgrant를 사용하고 Authorization Code를 사용하지 마세요.
8. 결론
OAuth 2.0은 “이 애플리케이션이 무엇을 할 수 있는가”라는 질문에 access token으로 답합니다. OIDC는 “사용자가 누구인가”라는 질문에 ID token으로 답합니다. 사용해야 할 흐름은 Authorization Code + PKCE이며, state와 nonce가 함께합니다.
Spring Boot 3에서 다음을 보았습니다:
spring-boot-starter-oauth2-client과oauth2Login은 클라이언트 쪽의 전체 OIDC 로그인 흐름을 처리합니다.spring-boot-starter-oauth2-resource-server는issuer-uri와audiences에 따라 JWT를 검증합니다.spring-security-test는 실제 authorization server 없이 scope에 따른 권한을 테스트할 수 있게 합니다.
다음으로 시도할 수 있는 것은 JwtAuthenticationConverter로 Keycloak의 role을 매핑하거나, SPA를 위해 클라이언트를 BFF로 분리하는 것입니다.