- 계약 채택 — 독자 질문, 후보 29건(PROMOTE 24 · MERGE_INTO 4 · KEEP_IN_SSOT 1). 게시 중 17건은 전부 유지. 저장소 keycloak-pattern 은 패턴 넷이 브랜치로 갈라져 있어 revisions 로 tip 넷을 적었다. keycloak-session-store 는 같은 저장소 @ cdac9b8 - 게시된 기록의 redirect_uri 가 SSOT·코드와 달랐다 — OAuth2callback.html → callback.html (frontend/src/app.js 에서 확인). 계약 title 이 기록과 다른 7건도 기록 쪽으로 맞췄다 - 미작성 1건 작성 — 패턴 검증을 실제로 돌릴 때의 안전한 순서(Reference) - 리뷰 100건 반영 — 설명 뒤에 붙은 평가·차례 예고·독자 오해 가정·작성 지시를 지웠다. 삭제가 남긴 조각 4건을 고치고, 원래부터 잘려 있던 로컬 미리보기 라벨 1건도 닫았다 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
128 lines
7.2 KiB
Markdown
128 lines
7.2 KiB
Markdown
---
|
|
id: 87000d59-b69f-4010-9481-0b71c8bde32d
|
|
kind: CONCEPT
|
|
slug: bearer-jwt-validation-chain
|
|
title: Bearer JWT가 인증된 principal이 되기까지
|
|
topic: oauth-oidc-auth-boundary
|
|
topicName: OAuth/OIDC 인증 경계
|
|
project: KeyCloak Patterns
|
|
status: 게시 전
|
|
version: 4
|
|
basisVersion: Keycloak 26.7.0 · Spring Security OAuth2 Resource Server
|
|
studio: "https://hyeonworks.com/studio/documents/87000d59-b69f-4010-9481-0b71c8bde32d/edit"
|
|
sourceRevision: keycloak-patterns-lab@2026-08
|
|
source:
|
|
- final/document.md#선택의-이유와-지킨-경계-ap1
|
|
---
|
|
|
|
# Bearer JWT가 인증된 principal이 되기까지
|
|
|
|
Resource Server가 받는 입력은 `Authorization` 헤더에 실려 온 문자열 하나다. 이 문자열은 서명 검증, issuer와 시간 검증, audience 검증, 역할 변환을 차례로 지나야 인증된 요청 주체(principal)가 된다. OAuth 2.0과 JWT를 한 번이라도 다뤄 본 사람을 대상으로, 먼저 Resource Server가 받는 입력부터 이 사슬이 단계마다 무엇을 확인하고 무엇을 다음 단계로 넘기는지 따라간다. 서명 검증을 통과했다고 이 API를 위해 발급된 토큰인 것은 아니고, 그 확인은 audience 검증이 따로 맡는다.
|
|
|
|
## 관계
|
|
|
|
- **OAuth Token과 Application Session을 구분하는 기준**
|
|
이 검증을 통과한 JWT와 애플리케이션 세션은 다른 상태다.
|
|
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
|
|
이 JWT가 어느 엔드포인트에서 발급되는지 정리한 기록이다.
|
|
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
|
|
브라우저가 이 헤더를 직접 만든 구성이다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
|
|
## Resource Server가 받는 입력
|
|
|
|
브라우저가 보내든 BFF(Backend For Frontend, 프런트엔드 전용 백엔드)가 보내든 요청의 모양은 같다.
|
|
|
|
```http label="Resource Server 입력"
|
|
GET http://localhost:8081/api/me
|
|
Authorization: Bearer <access-token>
|
|
```
|
|
|
|
Spring이 받는 것은 이 헤더에 실려 온 문자열 그대로다. 요청마다 JWT로 인증하고 애플리케이션 세션을 만들지 않으려고 `SecurityConfig.apiSecurity()`는 CORS를 켜고 CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조) 보호를 끄며 `SessionCreationPolicy.STATELESS`를 선택한다.
|
|
|
|
세션을 만들지 않으니 로그아웃하는 순간에 서버에서 지울 상태도 없다. 이미 발급된 JWT는 필요한 값을 자기 안에 담고 있어서 만료 전까지 유효하고, 짧은 TTL(Time To Live, 유효 시간)과 검증기가 그 범위를 좁힌다.
|
|
|
|
## 변환 순서
|
|
|
|
직접 만든 코드가 지나는 순서는 다음과 같다.
|
|
|
|
```text label="raw Bearer JWT가 principal이 되기까지"
|
|
raw Bearer JWT
|
|
→ NimbusJwtDecoder(JWK signature)
|
|
→ default issuer + timestamp validators
|
|
→ AudienceValidator("keycloak-pattern-api")
|
|
→ validated Jwt
|
|
→ KeycloakRealmRoleConverter
|
|
→ authenticated principal + ROLE_* authorities
|
|
```
|
|
|
|
헤더를 꺼내 JWT 인증 제공자에 넘기고 설정된 디코더를 부르는 일은 Spring OAuth2 Resource Server가 한다. 저장소의 코드는 Spring 내부 필터를 직접 만들지 않으므로, 이 순서에는 설정 DSL(Domain Specific Language, 설정 전용 문법)이 붙여 주는 부분과 직접 만들어 끼운 빈이 함께 들어 있다.
|
|
|
|
## 서명 확인 하나로 끝내지 않는 이유
|
|
|
|
서명이 맞다는 것은 그 IdP(Identity Provider, 인증 제공자)가 발급했다는 뜻이다. 서명 검증에 쓰는 JWK(JSON Web Key, 공개키를 JSON으로 표현한 형식)는 그 IdP의 공개키를 담고 있을 뿐이어서, 같은 IdP가 다른 API를 위해 발급한 토큰도 서명은 똑같이 맞다. 그래서 서명만 확인하고 끝내지 않는다.
|
|
|
|
| 확인 단계 | 확인하는 것 | 이 단계가 답하지 못하는 것 |
|
|
|---|---|---|
|
|
| JWK signature | 이 realm이 발급했는가 | 어느 API를 위한 토큰인가 |
|
|
| issuer | 기대한 realm인가 | 아직 유효한가 |
|
|
| timestamp | 만료 전인가 | 이 API가 대상인가 |
|
|
| audience | 이 API를 위해 발급됐는가 | 무엇을 할 수 있는가 |
|
|
| 역할 변환 | 어떤 권한을 갖는가 | — |
|
|
|
|
## expected issuer와 JWK URL이 다른 이유
|
|
|
|
두 값은 같은 realm을 가리키지만 쓰임이 다르다.
|
|
|
|
```text label="issuer와 JWK URL"
|
|
expected issuer = http://localhost:8080/realms/keycloak-patterns
|
|
JWK URL = http://keycloak:8080/.../certs
|
|
```
|
|
|
|
expected issuer는 토큰 안에 적혀 브라우저까지 보이는 값이다. 브라우저가 도달하는 주소로 발급됐으니 클레임을 검증하는 기준도 그 주소여야 한다. JWK URL은 공개키를 가져오는 컨테이너 네트워크 경로이고, Resource Server는 같은 Docker 네트워크 안에서 서비스 이름으로 Keycloak에 도달한다.
|
|
|
|
두 값을 같게 맞추려고 expected issuer를 컨테이너 주소로 바꾸면 브라우저가 받은 토큰의 `iss`와 어긋난다.
|
|
|
|
## audience 검증
|
|
|
|
audience는 이 토큰이 어느 API를 위해 발급됐는지 담는 값이다. `AudienceValidator`는 `jwt.getAudience()`에 `keycloak-pattern-api`가 들어 있는지 확인하고, 없으면 `invalid_token` 결과를 만든다.
|
|
|
|
정상적으로 발급받은 같은 JWT를 expected audience가 다른 진단용 Resource Server에 그대로 제출하면 401이 돌아온다. expected issuer가 다른 서버도 마찬가지로 401이다.
|
|
|
|
다만 이 두 서버가 재 준 범위는 audience 검증과 issuer 검증까지다. 서명이 틀린 JWT나 만료된 JWT를 넣는 전용 E2E 계약은 만들지 않았다. 단위 테스트에 합성 JWT를 주입해 컨트롤러가 200을 반환하는 것은 확인할 수 있지만, 그 200은 그 요청이 실제 `NimbusJwtDecoder`의 서명 검증과 issuer 검증을 지났다는 증거가 아니다. 사슬의 앞 두 단계는 이 계약 안에서 부정 입력으로 확인하지 않았다.
|
|
|
|
## realm role이 authority가 되는 변환
|
|
|
|
`KeycloakRealmRoleConverter`는 `realm_access.roles`에 들어 있는 문자열을 골라 앞에 `ROLE_`을 붙인다.
|
|
|
|
```text label="role 변환"
|
|
realm_access.roles: ["user-role"]
|
|
→ ROLE_user-role
|
|
```
|
|
|
|
Spring Security의 `hasRole("user-role")`이 `ROLE_user-role`이라는 이름의 권한을 찾기 때문에 이 접두사가 필요하다.
|
|
|
|
## 인증과 인가는 다른 endpoint에서 갈린다
|
|
|
|
`/api/me`는 특정 역할을 요구하지 않고 `.authenticated()`만 요구한다. 그래서 역할 클레임이 없어 변환 결과가 빈 목록이어도, JWT가 유효하기만 하면 `/api/me`는 통과한다.
|
|
|
|
`admin-role`의 효과는 `/api/admin`에서 나타난다. 일반 사용자는 403, 관리자 사용자는 200이다.
|
|
|
|
`ApiController`는 검증을 마친 JWT에서 값을 꺼내 사용자 JSON을 만든다.
|
|
|
|
```json label="ApiController가 반환하는 JSON"
|
|
{
|
|
"subject": "<keycloak-user-sub>",
|
|
"username": "regular-user",
|
|
"issuer": "http://localhost:8080/realms/keycloak-patterns",
|
|
"audience": ["<possibly-other-audiences>", "keycloak-pattern-api"]
|
|
}
|
|
```
|
|
|
|
이 JSON의 `regular-user`는 프록시가 만들어 붙이는 identity header에도 똑같이 들어갈 수 있는 값이다. 화면에 보이는 이름은 같아도 그 값을 무엇이 보증했는지는 다르다. 여기서는 서명과 issuer, audience를 확인한 JWT에서 꺼냈다.
|
|
|
|
<!-- body:end -->
|