--- 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`로 구현한다. > `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. ## 부모 (필수) [[raw/project-notes/keycloak-patterns-overview]] ## 브랜치 계약 패킷 - **생성 시 프로젝트 개정**: `1` - **패킷 스키마**: `contract_packet: 1` - **완료 조건**: foreign audience token 수용 실패를 재현하고 validator 적용 후 401을 검증한다 ### 상속한 프로젝트 결정 | 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]] | ### 브랜치 지역 결정 > 기존 branch-local 결정은 아래 `## Decision Evidence Map`의 D-row가 소유하며 이 packet에서 복제하지 않는다. | Decision ID | Decision | Relation | Supporting Claims | Status | |---|---|---|---|---| ### 선언한 예외 | Override ID | Overrides | Reason | Approval | Status | |---|---|---|---|---| 없음. ## 목표 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: (구현 없음) ## 범위 ### 포함 범위 - **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` 로 추가해 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:///realms/ ``` - 효과: Keycloak `/.well-known/openid-configuration` 자동 fetch → JWKS endpoint 발견 → JwtDecoder 자동 구성 - 자동 검증: signature + `iss == issuer-uri` + `exp` + `nbf` (clock skew 60s) - [ ] **JwtDecoder 빈 (복합 조건일 때만 커스터마이즈)** — 등급: `documented-only` - 기본 빈에 `OAuth2TokenValidator` 체인 추가 - `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 { 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:///realms/` → `/.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` 참조(중복 재작성 안 함). > > - **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` | 다중 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..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` ## 묶음 - [[raw/official-docs/keycloak-securing-apps-overview-official]] - [[raw/official-docs/spring-security-resource-server-jwt]] - [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] > 본 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` 유지.