Files
llm-wiki/raw/branch-notes/feature-keycloak-spring-rs-audience-validator.md

321 lines
34 KiB
Markdown

---
title: branch / feature-keycloak-spring-rs-audience-validator (Spring Security Resource Server + audience validator)
source_type: branch-note
status: raw
id: BR-KEYCLOAK-PATTERNS-OVERVIEW-004
kind: project-work-item
project: keycloak-patterns-overview
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-004
inherits: [DEC-KEYCLOAK-PATTERNS-OVERVIEW-SPA-CLIENT-001@1, DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1]
refines: []
overrides: []
depends_on: [WI-KEYCLOAK-PATTERNS-OVERVIEW-003]
contract_packet: 1
branch: feature-keycloak-spring-rs-audience-validator
parent_branch:
related_projects: [keycloak-patterns]
tags: [branch, keycloak-patterns, p2a, spring-security, resource-server, jwt, audience]
created: 2026-05-25
target_merge:
status_label: in-progress
contract_packet_sha256: 792d7570a248139de64c5bc1fd29f79218e3eea3388db85a4d18e6175344e3b3
---
# branch: feature-keycloak-spring-rs-audience-validator — Spring Security Resource Server + audience validator
> Layer: `raw/branch-notes/` — [[raw/project-notes/keycloak-patterns-overview]]의 `WI-KEYCLOAK-PATTERNS-OVERVIEW-004` 직접 branch.
> **목적**: Spring Security Resource Server 기본 JWT validator가 검증하는 항목과 별도 활성화가 필요한 `aud`를 분리한다. 단일 audience는 Boot `audiences` property를 baseline으로, 복합 조건은 custom `OAuth2TokenValidator<Jwt>`로 구현한다.
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
> **정합 노트 (2026-07-14 감사)**: 본 노트 = AP1 의 **`aud` 검증 + Spring RS 공통 셋업 owner** (hub Branch 분해 Tier-2 `feature-keycloak-spring-rs-audience-validator`). 형제 [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] 와 RS 셋업 내용이 겹치는데, 그쪽의 **role → 권한(RBAC)** 부분은 §5 **deferred authZ 트랙**으로 분리됨. 구현 시 RS 공통 코드·`aud` 검증은 본 노트가 owner.
<!-- section-id: branch-parent -->
## 부모 (필수)
[[raw/project-notes/keycloak-patterns-overview]]
<!-- GENERATED: branch-contract:start -->
<!-- section-id: branch-contract-packet -->
## 브랜치 계약 패킷
- **생성 시 프로젝트 개정**: `1`
- **패킷 스키마**: `contract_packet: 1`
- **완료 조건**: foreign audience token 수용 실패를 재현하고 validator 적용 후 401을 검증한다
<!-- section-id: inherited-project-decisions -->
### 상속한 프로젝트 결정
| Decision Ref | Project Summary | Branch Application | Source |
|---|---|---|---|
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-SPA-CLIENT-001@1` | AP1은 public client와 Authorization Code + PKCE를 사용한다 | AP1 Resource Server의 JWT audience 검증에 적용한다 | [[raw/project-notes/keycloak-patterns-overview]] |
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1` | done-bar는 E2E success와 signature security failure 재현·해결 evidence다 | foreign audience token 실패 재현과 401 evidence를 완료 조건으로 사용한다 | [[raw/project-notes/keycloak-patterns-overview]] |
<!-- section-id: branch-local-decisions -->
### 브랜치 지역 결정
> 기존 branch-local 결정은 아래 `## Decision Evidence Map`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
| Decision ID | Decision | Relation | Supporting Claims | Status |
|---|---|---|---|---|
<!-- section-id: declared-overrides -->
### 선언한 예외
| Override ID | Overrides | Reason | Approval | Status |
|---|---|---|---|---|
없음.
<!-- GENERATED: branch-contract:end -->
<!-- section-id: branch-goal -->
## 목표
Spring Security 6.x Resource Server는 `spring-boot-starter-oauth2-resource-server` + `issuer-uri` 설정만으로 자동으로 JWT signature / `iss` / `exp` / `nbf`를 검증한다. 그러나 **`aud` claim 검증은 기본 활성화 안 됨**. 같은 Keycloak realm 내 다른 client용으로 발급된 토큰이 본 backend로 흘러들어도 통과될 위험이 있다 (cross-client token reuse).
핵심 질문:
- Spring Security 기본 `JwtDecoder`가 검증하는 것 vs 검증하지 않는 것?
- `aud` claim은 왜 별도로 검증해야 하는가? (cross-client / cross-resource-server token reuse 차단)
- 다중 issuer 환경(multi-realm)에서 어떻게 처리하는가?
- JWKS cache 정책 (TTL, refresh, key rotation) 기본값은?
본 sub-sub-branch는 **의존성 → yml 설정 → 단일 audience property baseline → 복합 조건용 validator 비교**까지 정리한다. 프로젝트 expected audience의 단일 심볼은 `backend-client-id`다.
- 이슈: (학습 노트, 이슈 없음)
- PR: (구현 없음)
<!-- section-id: branch-scope -->
## 범위
### 포함 범위
- **Spring Security Resource Server 공통 셋업의 owner** — 의존성(`spring-boot-starter-oauth2-resource-server`) + `application.yml``issuer-uri` + `JwtDecoder` 빈 커스터마이즈(D6). 형제 [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] 가 이 셋업을 fold-in 으로 위임(정합 노트 2026-07-14).
- **`aud` claim 검증**(본 branch 고유 핵심, D1) — Spring 기본이 검증하지 않는 audience 를 Boot `audiences` property 또는 custom `OAuth2TokenValidator<Jwt>` 로 추가해 cross-client / cross-resource-server token reuse 를 차단.
- **Keycloak 발급 측 `aud` 주입 요건**(D4) — SPA client 의 client scope 에 Audience mapper 를 등록해 backend client_id 가 `aud` 에 포함되도록. 발급 설정은 검증 성립의 선행 조건.
- **검증 항목 매트릭스**(§구현 가이드 §3) — signature/`iss`/`exp`/`nbf``issuer-uri` 로 자동, `aud`/`azp`/`scope` 는 수동 추가 대상임을 분리.
### 제외 범위
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
- **role → 권한(RBAC) 매핑**(`realm_access.roles``@PreAuthorize`) — 형제 [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] 의 deferred authZ 트랙 소관(D3). 본 노트는 authN 토큰 검증까지만.
- **다중 issuer / multi-realm**(`JwtIssuerAuthenticationManagerResolver`) — 단일 realm 학습 범위 밖(D2, `UNSUPPORTED_DECISION`).
- **prod JWKS custom cache**(Caffeine 등) 튜닝 — 기본 cache 동작만 문서화(D5). 커스텀 cache 는 범위 밖.
- **실 구현 / 배포** — 실제 코드는 P3A [[raw/branch-notes/feature-keycloak-single-ec2-no-google]] + 형제 role-mapping 이 별도 keycloak-patterns repo(현재 미생성)에서 수행. 본 노트는 `documented-only` 설계·계약 층.
- **PKCE 발급 흐름 / 토큰 저장 위치 / refresh rotation** — 각 형제 sub-sub-branch owner 소관([[raw/branch-notes/feature-keycloak-pkce-flow-stages]] · [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] · [[raw/branch-notes/feature-keycloak-refresh-token-rotation]]). 본 노트는 발급된 토큰의 **검증 측**만.
## 근거 (필수, 최소 1개+)
- [[raw/official-docs/spring-security-resource-server-jwt]] — Spring Security Resource Server JWT 검증 reference
- [[raw/official-docs/oauth-v2-1-draft-ietf]] — OAuth 2.1 (audience binding 권고)
- [[raw/official-docs/keycloak-securing-apps-overview-official]] — Keycloak audience mapper
## TODO
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
- [ ] **의존성 정리** — 등급: `documented-only`
- `org.springframework.boot:spring-boot-starter-oauth2-resource-server`
- (선택) `org.springframework.security:spring-security-oauth2-jose` — 자동 포함
- Java 21 / Spring Boot 3.x / Spring Security 6.x 가정
- [ ] **`application.yml` issuer-uri 설정** — 등급: `documented-only`
```yaml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://<keycloak-host>/realms/<realm>
```
- 효과: Keycloak `/.well-known/openid-configuration` 자동 fetch → JWKS endpoint 발견 → JwtDecoder 자동 구성
- 자동 검증: signature + `iss == issuer-uri` + `exp` + `nbf` (clock skew 60s)
- [ ] **JwtDecoder 빈 (복합 조건일 때만 커스터마이즈)** — 등급: `documented-only`
- 기본 빈에 `OAuth2TokenValidator<Jwt>` 체인 추가
- `NimbusJwtDecoder.withIssuerLocation(issuerUri).build()` 사용
- `JwtValidators.createDefaultWithIssuer(issuerUri)` + custom validator를 `DelegatingOAuth2TokenValidator`로 결합
- [ ] **단일 audience baseline** — `spring.security.oauth2.resourceserver.jwt.audiences: backend-client-id` — 등급: `documented-only`
- [ ] **복합 audience validator 비교 학습 sketch** — 등급: `documented-only`
```java
public class AudienceValidator implements OAuth2TokenValidator<Jwt> {
private final String expectedAudience;
public OAuth2TokenValidatorResult validate(Jwt jwt) {
if (jwt.getAudience() != null && jwt.getAudience().contains(expectedAudience)) {
return OAuth2TokenValidatorResult.success();
}
return OAuth2TokenValidatorResult.failure(
new OAuth2Error("invalid_token", "Missing required audience", null));
}
}
```
- Keycloak `aud` claim 주의: 기본은 client_id가 `aud`로 들어가지 않을 수 있음 → Keycloak Client Scope의 **Audience mapper**를 추가해야 backend client_id가 `aud`에 포함됨
- [ ] **다중 issuer 환경 처리** — 등급: `documented-only`
- 단일 backend가 multi-tenant인 경우: `JwtIssuerAuthenticationManagerResolver.fromTrustedIssuers(...)` 사용
- 각 issuer마다 JwtDecoder 별도 캐싱
- 본 P2A 학습 범위는 단일 realm 기준 — multi-realm은 SSOT §8 자신 없는 부분에 있음
- [ ] **JWKS cache 정책** — 등급: `documented-only`
- 기본: 5분 cache (Spring Security `NimbusJwtDecoder` 기본 `Cache-Control` 따름)
- Keycloak 키 회전 시 `kid` mismatch 발생 → 자동 refresh (Spring Security가 unknown kid 시 JWKS 재fetch)
- prod에서는 `JwkSetUriJwtDecoderBuilder.cache(Cache)` 로 custom cache(Caffeine 등) 권장 — 학습 범위 외
- [ ] **검증 항목 매트릭스** — 등급: `documented-only`
| claim | Spring 기본 | 추가 필요 |
|-------|-------------|-----------|
| signature | ✅ (JWKS) | — |
| `iss` | ✅ | — |
| `exp` / `nbf` | ✅ (skew 60s) | — |
| `aud` | ❌ | ✅ 단일=`audiences: backend-client-id`, 복합=custom validator |
| `azp` (authorized party) | ❌ | (선택) 단일 client 강제 시 추가 |
| `scope` | ❌ (decoder 단계 아님) | `@PreAuthorize("hasAuthority('SCOPE_xxx')")` |
## 진행 중 메모
작업하며 떠오른 메모. 자유 형식.
- Keycloak의 `aud` claim 동작은 직관과 다름 — backend client는 보통 `bearer-only` 타입인데, SPA client가 backend의 client_id를 `aud`에 포함시키려면 SPA client scope에 **Audience mapper**를 추가해야 함. 안 그러면 `aud`는 `account`(realm 내장 client)만 들어감.
- `DelegatingOAuth2TokenValidator`로 default + audience를 묶는 패턴은 Spring Security 공식 reference의 audience validation 섹션 코드 그대로 적용 가능.
- **`/branch-spec` 채움 (2026-07-18)** — pre-template 노트를 템플릿 정합으로 보강: `선택 조건`(R2) 열 · In/Out scope · `## 구현 가이드`(§1 RS 셋업 · §2 audience validator · §3 검증 매트릭스) · `## 엣지·실패·의존` 추가. **NO_GROUND_TRUTH** — 본 branch 는 ca-tmpl 이 아니라 keycloak-patterns 학습 프로젝트이고 실 구현 repo(`/home/donghyeon/workspace/keycloak-patterns/`)가 아직 없어 전 항목 `documented-only`/`planned` 유지(코드 grep 불가). depth 게이트 = **Ready**(Blocking 0). 자가 보강: silent-bypass 엣지(validator 미합성 → `aud` 무검사 통과) + Audience mapper 프로비저닝 owner 포인터 추가. 미해소 Should-fix(연구 opt-in 필요): ① D4 의 "Keycloak 은 client_id 를 `aud` 에 자동 미포함"의 official verbatim 부재 → Keycloak Server Admin Guide §Client Scopes/Audience mapper 재발췌 필요, ② D7 의 access-token audience binding 근거가 refresh-token(`OA21-C3`)과 mismatch → OAuth 2.1 access-token best-practice § 재발췌, ③ §2 custom validator wiring 을 Spring Reference §Configuring Validation 재발췌로 supported 승격. 셋 다 `documented-only` 를 벗어나 문서 승급 전 종결 대상.
## 결정 사항 (decisions)
> 추후 면접/회고에서 "왜 이렇게 했나" 답할 근거. 대안과 함께 기록.
- 2026-05-25 (정합 2026-07-18): backend는 **`iss` + signature + `exp` + `aud`**를 검증한다. audience 검증 자체는 필수지만 단일 값 `backend-client-id`는 Boot `audiences` property가 baseline이고, custom validator는 다중 audience·`azp` 같은 복합 조건의 비교/확장 경로다.
- 2026-05-25: 다중 issuer는 학습 범위 외. 단일 realm 기준 정리.
- 2026-05-25: `JwtAuthenticationConverter`로 `realm_access.roles`를 Spring authorities로 매핑하는 것은 본 sub-sub-branch 범위에서 제외 (인가 영역).
## 결정-근거 매핑
> 본 branch Sources: Spring Security RS JWT (`official-vendor-doc`), OAuth 2.1 draft (`official-standard`), Keycloak securing apps overview (`official-vendor-doc`). 본 mapping 은 세 source 의 직접 인용 가능한 claim 만 사용.
> `선택 조건` 열(R2, 2026-07-18 `/branch-spec` 추가): "이 조건일 때 이 결정, 다른 조건이면 어떤 대안". 분기 없으면 `N/A`.
>
> **Ownership note** — 본 노트는 형제 [[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]] (부모 hub, AP1) 의 **D3(백엔드 4종 검증) 요약의 정본 owner** 이고, 형제 [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] 의 **RS 공통 셋업 + `aud` 검증** 을 fold-in 으로 흡수한다(정합 노트 2026-07-14). role→권한(RBAC)만 그쪽 deferred 트랙에 남는다.
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|---|---|---|---|---|---|
| D1 | backend는 `iss` + signature + `exp` + `aud`를 검증한다. expected audience는 **`backend-client-id` 하나**이며 단일 값은 Boot `audiences` property가 baseline | JWT를 직접 신뢰하는 Resource Server(AP1)면 audience 검증은 필수. 다중 audience/조건부 검증이면 custom `OAuth2TokenValidator`로 확장 | `raw/official-docs/spring-security-resource-server-jwt.md#SSRS-JWT-C1`, `#SSRS-JWT-C2`, `#SSRS-JWT-C6`, `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C3` | `official-vendor-doc + official-standard` | `backend-client-id`가 실제 Audience mapper와 token `aud`에 들어가는지는 realm export/token E2E 전까지 `needs-confirmation` |
| D2 | 다중 issuer 는 학습 범위 외, 단일 realm 기준 정리 | 단일 realm 학습 범위면 단일 `issuer-uri`. 한 백엔드가 **여러 realm(multi-tenant)** 토큰을 받으면 `JwtIssuerAuthenticationManagerResolver.fromTrustedIssuers(...)` 로 확장 — 본 학습 범위 밖(문헌으로 미조사, `UNSUPPORTED_DECISION` 유지) | UNSUPPORTED_DECISION (학습 범위 결정 — 외부 자료가 직접 뒷받침하지 않음. SSRS-JWT 의 `JwtIssuerAuthenticationManagerResolver` 언급은 본 branch raw 발췌에 포함되지 않음) | UNSUPPORTED_DECISION | 면접/포트폴리오에 multi-tenant Resource Server 경험 주장 금지. `documented-only` 등급 엄격 유지 |
| D3 | `JwtAuthenticationConverter` 로 `realm_access.roles` 를 Spring authorities 로 매핑하는 것은 본 sub-sub-branch 범위 제외 | **authN(누구인가)까지가 본 노트**. **authZ(realm role → 권한)** 가 필요하면 형제 [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] 의 deferred authZ 트랙. Spring default 는 `scope`/`scp` 만 매핑하므로 realm role 은 어느 쪽에서 하든 converter customize 필요 | `raw/official-docs/spring-security-resource-server-jwt.md#SSRS-JWT-C4` (scope/scp → SCOPE_ prefix default 동작) — Does not prove: "Keycloak realm role 이 default 로 자동 매핑된다는 뜻은 아님 — `realm_access.roles` 는 `JwtAuthenticationConverter` customize 필요" | `official-vendor-doc` | 본 결정은 범위 분리 — Spring default 가 Keycloak realm role 을 자동 매핑하지 **않는다** 는 SSRS-JWT-C4 의 Does-not-prove 와 정합. 형제 branch `feature-keycloak-spring-rs-role-mapping` 에서 다룸 |
| D4 | Keycloak 의 `aud` claim 에 backend client_id 가 자동 포함되지 않음 → SPA client 의 client scope 에 Audience mapper 등록 필수 | backend client_id 로 `aud` 를 검증하려는 모든 경우(= **D1 성립의 선행 조건**). Keycloak 기본은 client_id 를 `aud` 에 안 넣으므로(대신 `aud=account`) 발급 측 Audience mapper 없이는 audience 검증이 **항상 실패** → 대안 없음(발급 설정이 선행). audience 검증을 포기하면 D1 자체가 무너짐 | `raw/official-docs/keycloak-securing-apps-overview-official.md#KC-SECAPP-C1` (Keycloak 통합 일반 원칙), (보조) `raw/official-docs/spring-security-resource-server-jwt.md#SSRS-JWT-C6` (aud 검증 메커니즘) | `official-vendor-doc` | "Keycloak 기본 동작은 client_id 를 자동으로 `aud` 에 포함하지 않음" 의 직접 verbatim 은 본 branch Sources 의 KC-SECAPP-C1~C3 / SSRS-JWT-C1~C6 어디에도 없음 — Keycloak Server Administration Guide §Client Scopes / Audience mapper 정독으로 별도 corroborate 필요. 현재 본 branch 본문 운영 경험만 |
| D5 | JWKS cache 기본 정책 (5분, kid mismatch 시 자동 refresh) | 학습/기본 환경이면 Spring 기본 cache 동작에 위임. **prod 에서 회전 빈도·가용성 SLA** 가 빡세면 custom cache(Caffeine 등)로 교체 — 본 노트 범위 밖. `UNSUPPORTED`: 기본값 수치(5분)·refetch 동작 자체가 미검증(§Claims To Verify) | UNSUPPORTED_DECISION (본 branch Source 중 SSRS-JWT-C1~C6 어디에도 "5분 cache" 또는 "kid mismatch refresh" 의 verbatim quote 없음. Spring Security `NimbusJwtDecoder` cache 동작은 별도 § 또는 source code 정독 필요) | UNSUPPORTED_DECISION | 본 branch 본문 진술 ("기본 5분 cache", "unknown kid 시 JWKS 재fetch") 은 운영 경험/추정. 정확한 verbatim source 추출 필요 |
| D6 | `application.yml` 의 `issuer-uri` 한 줄로 OIDC discovery + JWKS 자동 fetch + iss/exp/nbf 자동 검증 (clock skew 60s) | authorization server 가 **OIDC discovery 지원**(Keycloak O)이면 `issuer-uri` 한 줄. discovery 미지원 또는 RS 가 **독립 부팅**(startup 시 AS ping 회피)을 요구하면 `jwk-set-uri` 병기(`SSRS-JWT-C5`). clock skew 는 기본값에 의존하되 시계 편차가 큰 환경이면 `JwtTimestampValidator` 로 override | `raw/official-docs/spring-security-resource-server-jwt.md#SSRS-JWT-C1`, `raw/official-docs/spring-security-resource-server-jwt.md#SSRS-JWT-C2` | `official-vendor-doc` | "clock skew 60s" 의 직접 verbatim quote 는 본 branch Source raw 발췌 (SSRS-JWT-C1~C6) 에 미포함 — Spring Security `JwtTimestampValidator` default 값. 별도 정확 확인 필요 |
| D7 | OAuth 2.1 draft 가 audience binding 을 권고한다는 진술 | N/A — 표준 근거 진술(결정 분기 아님). access token audience binding 의 직접 quote 는 **부분 corroborate**(인용된 `OA21-C3` 는 refresh token binding) → §Claims To Verify 로 access-token 측 § 재발췌 필요 | `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C3` (refresh token MUST be bound to scope + resource servers) | `official-standard` | OA21-C3 는 refresh token binding 만 직접 다룸. access token audience binding 의 직접 권고 quote 는 OAuth 2.1 draft 의 다른 § (e.g., §4.x 또는 §5.x token best practice) 별도 정독 필요. 현재 D7 은 부분 corroborate |
## 구현 가이드
> 본 branch 는 `documented-only` — 실 구현은 P3A(별도 keycloak-patterns repo, 현재 미생성)로 위임(§완료 후 정리). 따라서 산출물은 실행 코드가 아니라 **다음 구현자가 되묻지 않고 코드를 쓸 수 있는 사전 명세**다. 아래 sub-section 은 본 branch 의 결정(D1·D4·D6)에서만 도출하며, 형제 owner detail 은 포인터로 위임한다(`rules/consistency-contract.md` Reference-Only). 모든 실 구현 등급은 `planned`.
### 1. Spring Security Resource Server 셋업 (의존성 → yml → JwtDecoder 빈)
> **Trace**: D6(`SSRS-JWT-C1` issuer-uri→iss self-configure, `SSRS-JWT-C2` 4단계 deterministic discovery) + D1(`SSRS-JWT-C6` audiences 로 aud 검증). 형제 [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] 가 이 셋업을 fold-in 으로 소비한다 — 본 §가 그 공통 셋업의 owner.
>
> - **UNSUPPORTED_IMPL_DECISION**: clock skew 값(D6 본문의 "60s")·JWKS cache TTL(D5 의 "5분")은 인용 claim 이 보증하지 않는 Spring 기본값 — 아래 표에 `needs-confirmation` 으로 표기하고 §Claims To Verify 로 검증. 임의로 "60s/5분"을 명세에 각인하지 않는다.
| 단계 | 무엇 | 메커니즘 (되묻지 않을 명세) | 근거 | 등급 |
|---|---|---|---|---|
| 의존성 | Resource Server 활성화 | `org.springframework.boot:spring-boot-starter-oauth2-resource-server` (Java 21 / Spring Boot 3.x / Spring Security 6.x). `spring-security-oauth2-jose` 는 전이 포함 | `SSRS-JWT-C1` (RS 가 issuer-uri 로 self-configure) | `planned` |
| yml | discovery + 자동 검증 | `spring.security.oauth2.resourceserver.jwt.issuer-uri: https://<kc-host>/realms/<realm>` → `/.well-known/openid-configuration` fetch → JWKS 발견 → signature+`iss`+`exp`+`nbf` 자동 | `SSRS-JWT-C1`, `SSRS-JWT-C2` | `planned` |
| yml(대안) | AS ping 없이 독립 부팅 | discovery 미지원/독립 부팅이면 `jwk-set-uri` 병기 — 이때도 `issuer-uri` 는 유지(`iss` 검증 위해). startup 시 AS ping 안 함 | `SSRS-JWT-C5` | `planned` |
| 단일 audience | property로 audience 활성화 | `spring.security.oauth2.resourceserver.jwt.audiences: backend-client-id` | D1 / `SSRS-JWT-C6` | `planned` |
| JwtDecoder 빈 | 복합 조건일 때만 custom validator 합성 | `NimbusJwtDecoder`의 기본 validator를 보존하고 custom audience/azp 조건을 추가 | 아래 §2 `UNSUPPORTED_IMPL_DECISION` | `planned` |
| 시간 검증 | clock skew | 기본값 사용. 편차 큰 환경만 `JwtTimestampValidator(Duration)` override | D6 Open Risk — 기본값 수치 `needs-confirmation` | `planned` |
### 2. Audience validator (본 branch 고유 핵심 — D1)
> **Trace**: D1(`SSRS-JWT-C6` — Boot `audiences` property 가 `aud` 검증을 활성화, `iss` 또는 `aud` 불일치 시 실패) + D4(`KC-SECAPP-C1` — 발급 측 설정 선행). 검증 코드 sketch 는 §TODO 의 `AudienceValidator implements OAuth2TokenValidator<Jwt>` 참조(중복 재작성 안 함).
>
> - **UNSUPPORTED_IMPL_DECISION (핵심 갭)**: **Boot `audiences` property vs custom `OAuth2TokenValidator` 선택**. `SSRS-JWT-C6` 은 **property 방식만** 보증하고, custom validator + `DelegatingOAuth2TokenValidator` 로 default 와 합성하는 **정확한 wiring 은 인용 범위 밖**(그 claim 의 Does-not-prove 열이 "별도 §Configuring Validation 페이지 참조"로 명시). trade-off: **단일 audience** 면 property 한 줄이 단순·안전(권장), **다중 audience / 조건부(azp 병행 등)** 면 custom validator 가 필요 — 본 branch 는 학습상 custom 코드 sketch 를 보유하되 property 를 baseline 근거로 둔다. ▶ 후속(권장 next research): Spring Security Reference "Configuring Validation / Validating an Audience" 절을 `wiki-source-summarizer` 로 재발췌해 `SSRS-JWT-C7` 추가 → 이 갭을 supported 로 승격.
| 검증 방식 | 언제 | wiring | 근거 상태 |
|---|---|---|---|
| Boot `audiences` property | 단일 audience, Boot 3.x (**프로젝트 baseline**) | `spring.security.oauth2.resourceserver.jwt.audiences: backend-client-id` 한 줄 | **supported** (`SSRS-JWT-C6`) |
| custom `OAuth2TokenValidator<Jwt>` | 다중 audience / 조건부 로직 | §TODO sketch(`jwt.getAudience().contains(expectedAudience)`) + §1 의 `DelegatingOAuth2TokenValidator` 합성 | **UNSUPPORTED_IMPL_DECISION** (wiring 인용 범위 밖 — 위 참조) + §Claims To Verify |
| 발급 측 선행(Keycloak) | 두 방식 공통 | SPA client → Client Scopes → **Audience mapper**(Included Client Audience = `backend-client-id`) | D4 (`KC-SECAPP-C1` 보조 — runtime 확인 필요) |
### 3. 검증 항목 매트릭스 (무엇이 자동 / 무엇이 수동)
> **Trace**: D1 + D6. §TODO 의 "검증 항목 매트릭스" 를 명세로 승격 — 각 claim 이 `issuer-uri` 로 자동인지 수동 추가인지 확정.
| claim | Spring 기본 (`issuer-uri`) | 추가 필요 | 근거 |
|---|---|---|---|
| signature (JWKS) | ✅ 자동 | — | `SSRS-JWT-C2` (JWKS 로 public key 검증 strategy) |
| `iss` | ✅ 자동 | — | `SSRS-JWT-C1`, `SSRS-JWT-C2` |
| `exp` / `nbf` | ✅ 자동 (clock skew 기본값 — `needs-confirmation`) | — | `SSRS-JWT-C2` + D6 Open Risk |
| `aud` | ❌ | ✅ **§2 audience validator** | `SSRS-JWT-C6` |
| `azp` (authorized party) | ❌ | (선택) 단일 client 강제 시 | `UNSUPPORTED_IMPL_DECISION` — 본 branch Sources 에 `azp` 직접 인용 없음(§Claims To Verify). trade-off: OIDC Core §2 근거 필요, 현재 매트릭스 통찰만 |
| `scope` | ❌ (decoder 단계 아님) | `@PreAuthorize("hasAuthority('SCOPE_x')")` | `SSRS-JWT-C4` (scope→SCOPE_ prefix) |
| JWKS cache / kid 회전 | (기본 cache — `needs-confirmation`) | prod 는 custom cache | D5 `UNSUPPORTED_DECISION` (§Claims To Verify) |
## 엣지·실패·의존
> R4(깊이 게이트) 캡처용. 본 branch 는 `documented-only` 이나, audience 검증을 실제로 세울 때 부딪힐 실패/엣지와 다른 계약 의존을 미리 열거한다.
- **실패·엣지 경로**:
- **`aud` 미검증 → cross-client token reuse**: Spring 기본 validator 는 `aud` 를 보지 않으므로(`SSRS-JWT-C6` 은 property 를 켜야 검증됨) 같은 realm 의 **다른 client 토큰**이 본 백엔드에서 통과한다 — 본 branch 존재 이유. 기대 동작: audience validator 로 401(D1).
- **validator 미합성 → `aud` silent bypass (음성 테스트 필수, 가장 위험)**: §구현 가이드 §2 의 `DelegatingOAuth2TokenValidator` 합성을 틀리면 — audience validator 빈만 만들고 `JwtDecoder.setJwtValidator(...)` 등록을 누락하거나, default validator 를 덮어써 audience 를 미합성하는 경우 — `aud` 가 **조용히 무검사**로 통과한다. 위 "mapper 부재"의 loud 401 과 정반대로 **아무 에러 없이** cross-client 토큰이 통과해 "검증이 있다"는 착각을 남기는 가장 위험한 실패다. 기대 동작: 잘못된 `aud`(다른 client) 토큰이 **반드시 401** 임을 **음성 테스트**로 못박는다(형제 [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] §TODO 의 "잘못된 aud 토큰 → 401" 로컬 검증과 동일). Trace: §2 `UNSUPPORTED_IMPL_DECISION`(wiring 인용 범위 밖) + D1.
- **발급 측 mapper 부재 → 정상 토큰도 거부**: Keycloak 이 `aud` 에 backend client_id 를 안 넣으면(기본 `aud=account`) audience validator 가 **정상 사용자 토큰도 401**. 함정: 검증 코드가 맞아도 발급 설정이 빠지면 전 사용자 로그인 실패. 기대 동작: SPA client scope 에 Audience mapper 선행(D4). Audience mapper 의 실제 realm/client-scope 프로비저닝은 [[raw/branch-notes/feature-keycloak-realm-client-export]](realm export) 소관 — 본 노트는 요건(D4)만 owner.
- **JWKS 미가용 / kid 회전 mismatch**: Keycloak 키 회전 시 백엔드 캐시된 key 로 signature 검증 실패 → 새 kid 로 JWKS 재fetch 기대. 그러나 **cache TTL·자동 refetch 동작은 미검증**(D5 `UNSUPPORTED`). 기대 동작: 재fetch 로 자동 복구(가정), §Claims To Verify 로 확인.
- **clock skew 경계**: iat/exp 경계에서 발급자·검증자 시계 편차로 갓 발급된 토큰이 `nbf`/`exp` 에 걸릴 수 있음. 기대 동작: 기본 skew 허용 — 단 **기본값 수치 미검증**(D6). 편차 큰 환경은 `JwtTimestampValidator` override.
- **다중 audience 토큰**: `aud` 가 배열이고 backend client_id 를 **포함**하면 통과(`contains`). Boot property 방식과 custom `contains` 방식의 동작 차이(단일 vs 부분집합)는 §Claims To Verify 로 대조.
- **issuer-uri startup unreachable**: 백엔드 기동 시 Keycloak 미가용이면 discovery 실패로 **startup 실패**(`SSRS-JWT-C2` 는 첫 요청 시 discovery). 완화: `jwk-set-uri` 병기로 AS ping 회피(`SSRS-JWT-C5`) 또는 docker-compose `depends_on: healthy`(형제 role-mapping §마주친 문제가 지적). `UNSUPPORTED_IMPL_DECISION` — 완화책 선택 기준은 배포 branch 소관.
- **다른 계약 의존**:
- [[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]] (부모 hub, AP1) **D3** — 본 노트가 그 요약의 **정본 owner**. hub 의 §신뢰 경계 체크리스트 "`aud` 검증"·§토큰 교환 sequence step 7 이 본 노트 결정을 consume. 본 노트 D1/D4 가 바뀌면 hub 갱신 필요(비차단 전파).
- [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] D4~D6 — 본 D1/D6 위에 얹히는 deferred RBAC consumer. RS/audience detail은 그 문서가 소유하지 않는다.
- [[raw/project-notes/keycloak-patterns-overview]] §5 고정 결정 **F3**(단일 realm + 패턴당 client 1개) — D1 의 audience 검증이 client 를 구분하는 **전제**. client 를 분리하지 않으면 `aud` 로 client 를 구별할 수 없어 audience 검증이 무의미.
- [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] **D1** — 본 노트 D6 의 `iss` 검증이 성립하려면 Keycloak `KC_HOSTNAME` 고정으로 token `iss` 가 백엔드 `issuer-uri` 와 byte-level 일치해야 함. issuer 불일치 함정의 재현·해결은 그 branch 소관(single-EC2 맥락, 동일 원리).
- [[raw/branch-notes/feature-keycloak-pkce-flow-stages]] · [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] — 같은 AP1 그룹. 토큰이 **어떻게 발급·저장**되는지 전제이며 본 노트는 발급된 토큰의 **검증 측**만. 계약 의존은 약함(경계 구분 유지).
## 검증해야 할 주장
| Claim | Why uncertain | How to verify | Status |
|---|---|---|---|
| Boot `audiences: backend-client-id` baseline이 정상 token을 허용하고 wrong-audience token을 거부하는지 | 방식 선택은 D1에서 종결됐지만 runtime repo가 없음 | 정상 `aud`→200, 다른 client `aud`→401을 E2E 확인; 복합 조건이 생길 때만 custom 방식 비교 | `planned` |
| Keycloak SPA client 의 Client Scopes → Audience mapper 등록이 backend client_id 를 `aud` claim 에 정확히 포함시키는지 | 본 branch 본문 D4 - Keycloak 운영 경험 진술, verbatim Source 부재 | Keycloak admin console 에서 audience mapper 추가 → SPA 로그인 후 token decode 로 `aud` claim 에 backend client_id 포함 확인 | `planned` |
| `clock skew 60s` 가 Spring Security 6.x default 인지 + 어떤 property 로 override 가능한지 | 본 branch 본문 진술 — D6 의 verbatim Source 부재 | Spring Security `JwtTimestampValidator` source code 또는 `JwtValidators` factory method 의 default 값 확인 + reference doc 정확 quote 추출 | `needs-confirmation` |
| Spring Security `NimbusJwtDecoder` JWKS cache 기본 TTL 이 5분인지 + `kid` mismatch 시 자동 JWKS refetch 동작 | D5 가 UNSUPPORTED — verbatim Source 부재 | reference doc §Customizing the JwtDecoder 또는 `NimbusJwtDecoder.cache(...)` API doc 확인 | `needs-confirmation` |
| `azp` (authorized party) claim 검증 추가가 single-client 강제 시 실제 필요한지 + Keycloak 이 `azp` 를 발급 token 에 포함시키는지 | 본 branch 본문 검증 항목 매트릭스의 "선택" 항목 — Source 부재 | OIDC Core §2 ID token 의 `azp` 정의 정독 + Keycloak 발급 token 의 `azp` claim 실제 존재 확인 | `planned` |
| `JwtIssuerAuthenticationManagerResolver.fromTrustedIssuers(...)` 가 multi-realm 시나리오에서 각 issuer 마다 JwtDecoder 를 별도 캐싱하는지 | 본 branch 본문 진술 — 본 branch raw 발췌 (SSRS-JWT-C1~C6) 에 미포함 | Spring Security reference doc 의 multi-tenancy 섹션 정독 후 새 Claim 인용 추가 | `planned` |
| Keycloak realm role (`realm_access.roles` / `resource_access.<client>.roles`) 가 `JwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(...)` 로 추출 가능한지 | `SSRS-JWT-C4` 의 Does-not-prove 가 customize 필요 명시 — 정확한 claim name 미확정 | 형제 branch `feature-keycloak-spring-rs-role-mapping` 의 결정과 결합, 실제 token 의 `realm_access.roles` 구조 확인 후 converter 동작 검증 | `planned` |
| OAuth 2.1 draft 의 access token audience binding 직접 권고 quote 가 어느 § 에 위치 | D7 부분 corroborate — OA21-C3 는 refresh token 만 | OAuth 2.1 draft 전체 정독 → access token audience binding § 확인 후 Claim ID 추가 (OA21-C7 등) | `planned` |
## 마주친 문제
- 이슈 1: Keycloak에서 SPA client가 받는 토큰의 `aud` claim에 backend client_id가 안 들어감.
- 원인: Keycloak 기본 동작은 client_id를 자동으로 `aud`에 포함하지 않음. SPA client의 client scope에 audience mapper를 등록해야 함.
- 시도: (구현 없음)
- 해결: SPA client → Client Scopes → Add → Audience mapper (Included Client Audience = backend-client-id) — `documented-only`
## 묶음
<!-- GENERATED: sources:start -->
- [[raw/official-docs/keycloak-securing-apps-overview-official]]
- [[raw/official-docs/spring-security-resource-server-jwt]]
<!-- GENERATED: sources:end -->
<!-- GENERATED: branches:start -->
- [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]]
<!-- GENERATED: branches:end -->
> 본 sub-sub-branch 는 leaf — 자식 자료 없음. Phase 3 P3A 실 구현 또는 외부 산출물 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
### 오류 기록 (이 sub-sub-branch 작업 중 발생)
- (없음 — 현재 documented-only 단계)
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
- (없음 — Phase 3 실 구현 단계에 누적)
## 관련 일일 노트
## 완료 후 정리
> 머지/종료 시점에 채움. `/ingest`가 이 섹션을 기준으로 wiki/projects/에 추출.
- PR 링크: (미구현 — 문서까지만)
- 리뷰 메모:
- 머지 결과 / 배포 환경: 없음 (P2A는 `documented-only` 범위)
- **wiki 추출 대상**: 현 단계 없음. 추후 P3A 구현 후 `wiki/concepts/spring-security-jwt-validation.md`로 합성 검토 가능.
- **추출하지 않을 항목**: P2A 구현 없음. `documented-only` 유지.