Files
llm-wiki/raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch.md
T

333 lines
49 KiB
Markdown

---
title: branch / feature-keycloak-iss-claim-hostname-mismatch (iss claim mismatch 함정 + KC_HOSTNAME 해결)
source_type: branch-note
status: raw
id: BR-KEYCLOAK-PATTERNS-OVERVIEW-005
kind: project-work-item
project: keycloak-patterns-overview
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-005
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-iss-claim-hostname-mismatch
parent_branch:
related_projects: [keycloak-patterns]
tags: [branch, keycloak-patterns, p3a, implementation, kc-hostname, iss-mismatch, troubleshooting]
created: 2026-05-25
target_merge:
status_label: in-progress
contract_packet_sha256: a528d5f258776792a6803ccea4e7946bc7905bbc6abee798b12b4c68a9afda66
---
# branch: feature-keycloak-iss-claim-hostname-mismatch (iss claim mismatch 함정 + KC_HOSTNAME 해결)
> Layer: `raw/branch-notes/` — [[raw/project-notes/keycloak-patterns-overview]]의 `WI-KEYCLOAK-PATTERNS-OVERVIEW-005` 직접 branch.
> **P3A는 실 구현 대상**. 본 sub-sub는 학습 + 작업 plan 기록 — 실 구현은 `/home/donghyeon/workspace/keycloak-patterns/`.
> ⚠️ **NO_GROUND_TRUTH (2026-07-17 확인)**: `/home/donghyeon/workspace/keycloak-patterns/` 는 **디스크에 존재하지 않는다**. [[raw/project-notes/keycloak-patterns-overview]] §9 도 "아직 비어 있음 — Phase 2 진입 시 생성" 으로 기록. 따라서 본 노트의 모든 구현 항목은 `planned` 이며, 코드로 확인된 `actually-implemented` 는 **0건**이다.
<!-- section-id: branch-parent -->
## 부모 (필수)
[[raw/project-notes/keycloak-patterns-overview]]
<!-- GENERATED: branch-contract:start -->
<!-- section-id: branch-contract-packet -->
## 브랜치 계약 패킷
- **생성 시 프로젝트 개정**: `1`
- **패킷 스키마**: `contract_packet: 1`
- **완료 조건**: hostname 미설정 iss mismatch 401과 설정 후 복구 log가 존재한다
<!-- 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를 사용한다 | SPA access token의 issuer 검증과 Keycloak hostname wiring에 적용한다 | [[raw/project-notes/keycloak-patterns-overview]] |
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1` | done-bar는 E2E success와 signature security failure 재현·해결 evidence다 | iss mismatch 401과 설정 후 복구 log를 완료 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 -->
## 목표
단일 EC2의 **가장 흔한 함정**을 의도적으로 재현하고 해결한다. browser는 `localhost:8080`(또는 EC2 public DNS)으로 Keycloak에 접근하지만, backend는 Docker internal network `keycloak:8080`을 보면서 JWT의 `iss` claim이 mismatch — JWT validation 실패. `KC_HOSTNAME` 설정으로 해결.
면접 질문: "단일 호스트 docker-compose에서 OIDC가 동작 안 했던 경험이 있나요?"
→ "browser가 보는 issuer identity는 `http://localhost:8080`으로 고정하고, backend의 `issuer-uri`도 token `iss`와 같은 localhost 값을 사용합니다. 실제 JWKS fetch는 `jwk-set-uri=http://host.docker.internal:8080/.../certs`로 분리합니다. 이 구성은 아직 `planned`이며, 401→200과 key rotation을 로컬에서 검증해야 합니다."
> **2026-07-18 `/sync` 채택 (D6)**: Docker dev 기본은 **issuer identity와 JWKS network address 분리**다. `issuer-uri=http://localhost:8080/realms/keycloak-patterns`, `jwk-set-uri=http://host.docker.internal:8080/realms/keycloak-patterns/protocol/openid-connect/certs`를 사용하고 backend에 `extra_hosts: ["host.docker.internal:host-gateway"]`를 둔다. `SSRS-JWT-C5`에 따라 앞 값은 `iss` 문자열 검증, 뒤 값은 실제 key fetch를 담당한다. 실제 구현·검증 전까지는 `planned`이며 과거형 경험으로 표현하지 않는다.
- 이슈:
- PR: (별도 keycloak-patterns repo)
<!-- section-id: branch-scope -->
## 범위
### 포함 범위
- 의도적 실패 재현 (`KC_HOSTNAME` 미설정 / `KC_HTTP_ENABLED=true`만)
- backend Spring 로그에 `JWT issuer mismatch` 또는 `Could not validate iss` 에러 확인
- `KC_HOSTNAME=localhost` 설정 후 양쪽 issuer 일치 검증
- 해결 방안 4가지 비교 (2026-07-17: 기존 3가지 A/B/C 에 **F 추가** — 조사 결과 F 가 가장 이식성 높은 해법으로 판정, D6):
- (A) `KC_HOSTNAME=localhost` + 컨테이너 간 `extra_hosts: [host.docker.internal:host-gateway]` → backend가 `http://host.docker.internal:8080`으로 JWKS 호출
- (B) `network_mode: host` (Docker hairpin NAT — Linux only)
- (C) backend container `/etc/hosts``keycloak`을 host gateway에 매핑 (extra_hosts 응용)
- **(F, 채택)** Spring `issuer-uri` / `jwk-set-uri` 분리 — `issuer-uri`는 localhost token `iss` 검증, `jwk-set-uri``host.docker.internal`을 통한 실제 JWKS fetch. Linux backend에는 `extra_hosts: ["host.docker.internal:host-gateway"]`를 둔다. 근거 `SSRS-JWT-C5` + `DOCKER-COMPOSE-NET-C3/C4`.
- EC2 환경에서 `KC_HOSTNAME=ec2-xx-xx-xx-xx.compute.amazonaws.com` 설정 시 변화 (브라우저가 EC2 public DNS로 접근)
- frontchannel/backchannel URL 분리 옵션 (`KC_HOSTNAME_BACKCHANNEL_DYNAMIC=true`, Keycloak 24+)
### 제외 범위
- HTTPS / Let's Encrypt cert 발급 (학습 환경 HTTP)
- EC2 보안그룹 / VPC 세팅
- Caddy / nginx reverse proxy 앞단 추가
- **`aud` (audience) claim 검증** — 프로젝트 노트 §중복 정합(2026-07-14) 이 `feature-keycloak-spring-rs-audience-validator` 를 owner 로 지정. 본 branch 는 `iss` 만 다룬다.
- **realm / client 생성 및 export** — [[raw/branch-notes/feature-keycloak-realm-client-export]] 소유.
- **`network_mode: "service:<name>"` (E) 패턴** — 2026-07-17 조사가 발견한 5번째 대안이나, 더미 anchor 컨테이너 의존 + 포트 관리 비용이 서비스 2개 규모에 과설계라 채택 안 함 (§Audit & Findings A4).
## 근거 (필수, 최소 1개+)
> 이 branch의 구현·설계 결정의 **근거가 되는 외부 자료**. (2026-07-17 정리: 병행 dispatch 로 생긴 중복 항목 제거 + `## Cluster` 하위에 잘못 생성된 `### Sources` 서브섹션을 본 표로 통합.)
| Source | 정당화하는 결정 |
|---|---|
| [[raw/official-docs/keycloak-hostname-configuration]] | D1 — Keycloak hostname guide. `hostname` 설정 의무(`KC-HOST-C2`) + fraudulent issuer 방지(`KC-HOST-C3`) + backchannel 분리 capability(`KC-HOST-C1`,`C4`) + hostname-strict 기본 `true`(`KC-HOST-C5`) |
| [[raw/official-docs/spring-security-resource-server-jwt]] | **D6 의 핵심 근거**`issuer-uri` 는 token `iss` 값이어야 하고 RS 가 이 값으로 self-configure(`SSRS-JWT-C1`), discovery 4단계 중 4번이 `iss` 비교(`SSRS-JWT-C2`), **`jwk-set-uri` 지정 시 discovery 를 하지 않으며 `issuer-uri``iss` 검증용으로만 남음**(`SSRS-JWT-C5`). D5 의 "iss 검증 = 신뢰의 본질" 보조 근거. ⚠️ 2026-07-17 이전까지 **이 branch Sources 에 링크되지 않아 D6 를 놓치고 있었음** (§Audit & Findings A1) |
| [[raw/official-docs/docker-compose-networking-extra-hosts-official]] | D6 — `extra_hosts` custom hostname 매핑(`DOCKER-COMPOSE-NET-C3`) + `host-gateway` 특수값(`C4`) + Linux vs Mac/Win 해석차(`C5`) = 해결 A/C 의 메커니즘 근거. **서비스명 internal DNS 가 별도 설정 없이 도달**(`C1`,`C2`) = 해결 F 의 도달성 근거 |
| [[raw/official-docs/docker-host-network-driver-official]] | D6 — 해결 (B) `network_mode: host` 의 플랫폼 제약. Linux native + **Docker Desktop 4.34+ opt-in**(`DOCKER-HOSTNET-C1`,`C2`), Windows 컨테이너 미지원(`C3`), **`ports:` 무시**(`C4`), Desktop 은 layer 4 한정(`C5`) |
| [[raw/official-docs/docker-engine-20-10-release-notes-official]] | D6 — `host.docker.internal` 의 Linux dockerd 지원이 20.10.0(2020-12-08)에서 시작(`DOCKER-2010-C1`). 본문 "최소 Docker 20.10+" 메모의 **부분 confirm** 근거 |
| [[raw/official-docs/keycloak-2500-hostname-v2-release-official]] | D7 — hostname v2 도입(25.0.0) 사유·동작 변경 경고·v1 deprecated(`KC-2500-C1`~`C4`). 본문 "옵션 명칭이 자주 바뀜" 메모의 **프레이밍 정정** 근거 |
| [[raw/official-docs/keycloak-2600-hostname-v1-removed-official]] | D7 — 26.0.0 에서 hostname v1 **완전 제거**(`KC-2600-C1`) + `proxy` 옵션 제거(`C2`). 이 프로젝트가 26.x 고정이므로 **v2 가 유일 옵션 집합**임을 확정 |
| [[raw/official-docs/openid-connect-core-id-token-validation]] | D5 — "RFC 7519 + OIDC Core spec 모두 `iss` 검증을 mandatory 로 규정" 진술의 미증명 상태를 해소. §3.1.3.7 item 2 (`iss` MUST exactly match, `OIDC-CORE-C3`) verbatim quote 가 직접 근거 |
## TODO
- [ ] **실패 재현**: `docker-compose.yml`에서 `KC_HOSTNAME` 제거, `KC_HTTP_ENABLED=true`만 — 등급: `planned`
- [ ] browser로 `http://localhost:8080`에서 로그인 → SPA가 token 받음 → `/api/me` 호출 → backend 401 — 등급: `planned`
- [ ] backend 로그에서 `JwtValidationException` / `iss claim did not match` 메시지 캡처 → screenshot/로그 발췌 — 등급: `planned`
- [ ] decode된 access token의 `iss` claim 캡처 (jwt.io 사용) — 등급: `planned`
- [ ] **해결 (A)**: `KC_HOSTNAME=localhost` 설정 + backend `extra_hosts: ["host.docker.internal:host-gateway"]` + `application.yml` `issuer-uri: http://host.docker.internal:8080/...` — 등급: `planned`
- 단점: backend가 보는 issuer-uri와 token 안의 iss가 또 mismatch
- 사실 정답은: **token issuer와 backend issuer-uri를 정확히 일치**시키는 것
- > (2026-07-17 조사) 이 자기 진단은 **정확했다** — A 원안은 `iss` 불일치로 기능적으로 실패한다. 다만 "정확히 일치" 를 *네트워크 도달성까지 같은 URL 로* 달성해야 한다는 전제는 `SSRS-JWT-C5` 기준 **틀렸다**(F 참조).
- [ ] **해결 (정답 재정의)**: `KC_HOSTNAME=localhost` + backend `issuer-uri: http://localhost:8080/realms/keycloak-patterns` + backend container가 `localhost`를 host gateway로 매핑 → 컨테이너 내부 `localhost:8080`이 호스트 8080으로 라우팅 — 등급: `planned`
- [ ] **해결 (B)**: `network_mode: host` 시도 (Linux only) → backend가 host network share → `localhost:8080` 직접 도달 — 등급: `planned`
- [ ] **해결 (C)**: backend container `extra_hosts: ["localhost:host-gateway"]` 또는 `keycloak:host-gateway` 후 issuer-uri 정렬 — 등급: `planned`
- [ ] **해결 (F, 기본)**: `KC_HOSTNAME=localhost` 유지 + backend `issuer-uri: http://localhost:8080/realms/keycloak-patterns` + `jwk-set-uri: http://host.docker.internal:8080/realms/keycloak-patterns/protocol/openid-connect/certs` + `extra_hosts: ["host.docker.internal:host-gateway"]` → 401→200 확인 — 등급: `planned`
- [ ] **EC2 시나리오**: `KC_HOSTNAME=ec2-xx.compute.amazonaws.com` 설정 → browser는 public DNS로 접근, backend도 동일 hostname을 issuer-uri로 — 등급: `planned`
- [ ] (Keycloak 24+) `KC_HOSTNAME_BACKCHANNEL_DYNAMIC=true` 시연: frontchannel은 public hostname, backchannel은 container DNS 자동 분리 — 등급: `planned`
- [ ] 네 해결 방안 비교 노트 (장단점 표) — 등급: `planned` (2026-07-17: 3→4개. §구현 가이드 §2 의 표가 사전 명세, 실측 후 이 표로 확정)
- [ ] 학습 정리: "왜 iss claim 검증이 신뢰의 핵심인가" 설명문 작성 — 등급: `planned`
## 진행 중 메모
- **iss 검증이 신뢰의 본질**: 만약 backend가 `iss` 검증을 안 하면 다른 Keycloak realm(또는 가짜 IdP)의 token도 통과. RFC 7519 + OIDC Core spec 모두 `iss` 검증을 mandatory로 규정.
- > (2026-07-17) "RFC 7519 + OIDC Core 가 mandatory 로 규정" 은 **본 branch Sources 로 미증명** — RFC/OIDC 원문이 Sources 에 없다. Spring 측은 `SSRS-JWT-C2`(discovery 4단계의 4번이 `iss` 비교)로 *동작*은 확정되나, *스펙이 mandatory 라고 규정한다*는 진술은 별개. §Claims To Verify 참조.
- **Spring `issuer-uri`는 두 가지 역할**:
1. OIDC discovery (`/.well-known/openid-configuration`) URL 생성 — JWKS endpoint 자동 찾기
2. JWT `iss` claim 검증 시 기대값
- > (2026-07-17 **핵심**) 이 메모가 D6 의 씨앗이었다. `SSRS-JWT-C5` 는 이 **두 역할이 분리 가능**함을 공식으로 확정한다 — `jwk-set-uri` 를 주면 Spring 은 역할 1(discovery)을 아예 수행하지 않고, `issuer-uri` 는 역할 2(문자열 검증)만 남는다. 함정의 원인은 "두 역할이 한 값에 묶여 있다" 는 **기본 auto-config 의 우연한 결합**이지 OIDC 의 요구가 아니다.
- **token 안의 `iss`는 Keycloak이 박음** — `KC_HOSTNAME`이 결정. backend가 어디서 JWKS를 fetch하든 token 안의 `iss`와 backend 기대값이 일치해야 함.
- > (2026-07-17) "backend 가 **어디서 JWKS 를 fetch 하든**" — 이 표현이 정확히 F 의 원리다. 작성 시점엔 원리를 적어두고 해결 방안에는 반영하지 않았다(§Audit & Findings A1).
- **함정 변형**: browser는 EC2 public DNS, backend는 docker internal — Keycloak이 어느 hostname으로 발급할지가 `KC_HOSTNAME`에 의존. 만약 미설정이면 Keycloak이 request Host 헤더 기준으로 추측 → 변동성 발생.
- > (2026-07-17) `KC-HOST-C2`(hostname 설정 의무 + dynamic resolution 차단)와 정합. 단 **미설정 시 startup 이 실패하는지 vs 추측하는지**는 hostname guide 의 §Usage Boundaries 가 명시적으로 "본 인용에 없음" 이라고 적은 항목 — §Claims To Verify.
- **`KC_HOSTNAME_BACKCHANNEL_DYNAMIC`**: Keycloak 24+ 신기능. frontchannel URL은 `KC_HOSTNAME` 고정, backchannel(=internal service-to-service)은 request로부터 동적으로 결정. 단일 host에서 매우 유용.
- > (2026-07-17 **정정 2건**) ① "24+ 신기능" → 정확히는 **hostname v2(25.0.0 도입, `KC-2500-C1`/`C3`)의 옵션**이며 26.0 에서 v1 이 제거되어(`KC-2600-C1`) 26.x 에선 v2 가 유일. v1 의 대응 옵션은 `hostname-strict-backchannel` 로 **이름뿐 아니라 boolean 극성이 반대**였다. ② "단일 host 에서 매우 유용" → **본 branch 에선 유용하지 않다.** Spring 기본 auto-config 는 `issuer-uri` 문자열로 discovery 를 호출하므로(`SSRS-JWT-C2`), Keycloak 이 backchannel URL 을 어떻게 응답하든 **backend 의 discovery 호출 자체가 `localhost`(=자기 자신)로 나가 네트워크 단계에서 먼저 실패**한다. D 를 쓰려면 결국 F 와 같은 Spring 측 분리 설정이 필요 → D 단독의 부가가치는 "여러 client 종류를 한 곳에서 관리" 에 국한 (D6 참조).
## 결정 사항 (decisions)
- 2026-05-25: **`KC_HOSTNAME=localhost` 강제.** 이유: 학습 단계 일관성, P3A 본질 함정 시연.
- 2026-05-25: **세 해결 방안 모두 학습.** 이유: 면접에서 "왜 A가 아니라 B를 골랐냐"에 답하려면 비교가 필수.
- 2026-05-25: **EC2 시나리오는 docker-compose 환경에서 시뮬레이션만**. 실제 EC2 배포는 별도 마일스톤.
- 2026-05-25: **실패 재현을 먼저, 해결을 뒤에.** 이유: 함정의 원인을 코드/로그로 직접 보지 않으면 학습 효과 낮음.
- 2026-07-18: **기본 해결 경로 = (F) issuer/JWKS 분리**. `KC_HOSTNAME=localhost``issuer-uri=localhost`로 identity를 고정하고, `jwk-set-uri=host.docker.internal`로 network address를 분리한다. C(`localhost:host-gateway`)는 `/etc/hosts` 중복 우선순위 때문에 fallback 비교군으로 강등한다.
- 2026-07-17: **`hostname-strict=false` 는 해법 후보에서 제외.** 이유: ① 보안 — dynamic hostname 해석은 `KC-HOST-C3` 의 fraudulent issuer 위험을 정면으로 허용, ② **기능 — 애초에 이 함정을 해결하지 못한다** (request 마다 `iss` 가 달라져 문자열 일치가 아예 불가). 학습 환경이라도 채택 안 함. 근거: [[raw/official-docs/keycloak-hostname-configuration]]
## 결정-근거 매핑
> 각 결정의 직접 근거. `선택 조건` 열(R2)은 "이 조건일 때 이 결정, 다른 조건이면 어떤 대안" — 분기 없으면 `N/A`.
> (2026-07-17) 기존 D1~D5 의 Supporting Claims 는 보존하고, 자동조사로 확정된 근거를 반영해 D2 의 Open Risk 를 갱신 + D6·D7 신규 추가.
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|---|---|---|---|---|---|
| D1 | `KC_HOSTNAME=localhost` 강제 (학습 단계 일관성, P3A 본질 함정 시연) | 단일 호스트 학습 환경에서 browser 접근점이 `localhost` 일 때. **대안**: browser 가 EC2 public DNS 로 접근하면 `KC_HOSTNAME=<public DNS>` (D3 의 시뮬레이션 범위). **hostname 미설정은 선택지가 아님**`KC-HOST-C2` 가 설정을 의무화 | `raw/official-docs/keycloak-hostname-configuration.md#KC-HOST-C2` (hostname 설정 의무), `raw/official-docs/keycloak-hostname-configuration.md#KC-HOST-C3` (fraudulent issuer 방지) | `official-vendor-doc` | hostname-strict=false는 D7에 따라 제외. 최종 wiring owner는 [[raw/branch-notes/feature-keycloak-single-ec2-no-google]] D3이며 본 행은 함정 시연 범위다. |
| D2 | 세 해결 방안 모두 학습 (A: KC_HOSTNAME + extra_hosts, B: network_mode host, C: extra_hosts 응용) | 면접에서 "왜 A 가 아니라 B 냐" 에 답해야 하므로 **비교 자체가 목표** — 하나만 실습하는 대안은 학습 목표상 기각. (2026-07-17: 비교 대상이 A/B/C → **A/B/C/F** 로 확장, D6 가 선택 기준을 부여) | `raw/official-docs/keycloak-hostname-configuration.md#KC-HOST-C1` (frontchannel/backchannel 분리 capability), `raw/official-docs/keycloak-hostname-configuration.md#KC-HOST-C4` (backchannel-dynamic full URL 요구) | `official-vendor-doc` | ~~`network_mode: host` (Linux only) / `extra_hosts: host-gateway` (Docker 20.10+) 의 동작은 본 Source 가 직접 증명하지 않음 — Docker 공식 doc 별도 필요~~**2026-07-17 해소**: `DOCKER-HOSTNET-C1`~`C5`, `DOCKER-COMPOSE-NET-C3`~`C5`, `DOCKER-2010-C1` 아카이브 완료. **판정 2건**: "Linux only" = **부분 refute**(Desktop 4.34+ opt-in 지원), "20.10+" = **부분 confirm**(`host.docker.internal` 은 20.10.0 확정, `host-gateway` 리터럴 자체의 도입 버전은 release notes 로 미확정 — moby/moby#40007 원문 필요) |
| D3 | EC2 시나리오는 docker-compose 환경에서 시뮬레이션만, 실제 EC2 배포는 별도 마일스톤 | 학습 목표가 *iss 함정의 이해* 이고 EC2 운영이 아닐 때. **대안**: 실제 EC2 배포는 프로젝트 §Phase 진행 후 별도 마일스톤 | UNSUPPORTED_DECISION (운영 우선순위 결정) | UNSUPPORTED_DECISION | 실제 EC2 배포 미수행 — 면접/포트폴리오에 EC2 운영 경험을 주장하면 안 된다. Docker dev의 `host.docker.internal` 선택을 EC2/prod 값으로 일반화하지 않는다. |
| D4 | 실패 재현을 먼저, 해결을 뒤에 (함정의 원인을 코드/로그로 직접 보지 않으면 학습 효과 낮음) | 학습 목적 branch 일 때. **대안**: 납기 압박이 있는 실무 branch 라면 해결부터 (본 branch 는 해당 없음) | UNSUPPORTED_DECISION (학습 방법론 결정 — 외부 자료가 뒷받침하지 않는 본 branch 본문의 자체 판단) | UNSUPPORTED_DECISION | 본 결정은 학습 효율 가설. 결과 측정 (실패 재현 전후 이해도 차이) 자체로만 verified 가능 — 외부 source corroborate 불가 |
| D5 | "iss 검증이 신뢰의 본질, iss 검증을 안 하면 다른 realm/가짜 IdP token 통과" 라는 진행 중 메모 통찰 | N/A (분기 없는 원리 진술) | `raw/official-docs/keycloak-hostname-configuration.md#KC-HOST-C3` (fraudulent issuer 방지 rationale), `raw/official-docs/spring-security-resource-server-jwt.md#SSRS-JWT-C1`, `raw/official-docs/spring-security-resource-server-jwt.md#SSRS-JWT-C2` (issuer-uri 와 token iss 정확 일치 검증) — **2026-07-17: Spring RS 를 본 branch Sources 에 정식 링크 완료** (기존 "보조 인용" 단서 해소) | `official-vendor-doc` | "RFC 7519 + OIDC Core spec 모두 iss 검증을 mandatory" 라는 본문 진술은 본 branch Source (Keycloak hostname + Spring RS) 가 직접 증명하지 않음 — 두 Source 는 *구현 동작*만 증명. RFC 7519 §4.1.1 또는 OIDC Core §3.1.3.7 정독으로 corroborate 필요 (미해소) |
| D6 | **기본 = (F) issuer identity/JWKS network address 분리**`issuer-uri=http://localhost:8080/realms/keycloak-patterns`, `jwk-set-uri=http://host.docker.internal:8080/realms/keycloak-patterns/protocol/openid-connect/certs` | Docker dev bridge profile에서 token identity는 browser-visible localhost로 유지하고 backend JWKS fetch만 host gateway로 보낸다. Linux에서는 backend `extra_hosts: ["host.docker.internal:host-gateway"]`가 필요하다. C/B는 fallback 비교군 | `raw/official-docs/spring-security-resource-server-jwt.md#SSRS-JWT-C5`, `raw/official-docs/docker-compose-networking-extra-hosts-official.md#DOCKER-COMPOSE-NET-C3`, `#DOCKER-COMPOSE-NET-C4` | `official-vendor-doc` | realm/JWKS path와 key rotation cache는 runtime 미검증 → `needs-confirmation`. 실제 401→200 및 key rotation E2E 전에는 `planned` |
| D7 | **`hostname-strict=false` 를 해법 후보에서 제외** | N/A — 조건부 아님(단정적 제외). 유일 예외: `hostname-debug=true` 와 함께 *동적 해석 동작 관찰용*으로 일시 사용 후 원복 | `raw/official-docs/keycloak-hostname-configuration.md#KC-HOST-C3` (명시적 hostname 설정이 fraudulent issuer 를 방지), `raw/official-docs/keycloak-hostname-configuration.md#KC-HOST-C5` (strict 기본 `true`, prod 는 항상 true 권장), `raw/official-docs/keycloak-hostname-configuration.md#KC-HOST-C2` (기본 동작이 dynamic resolution 을 차단하는 것이 보안 조치) | `official-vendor-doc` | 조사가 확보한 "hostname 설정 시 strict 무시 / hostname 미설정 시 backchannel-dynamic 강제 false" **Validations 규칙**과 password-reset 링크 조작 공격 시나리오는 **아직 아카이브 안 됨** (현행 hostname guide 의 신규 claim 후보 `KC-HOST-C6`). 제외 결정 자체는 위 3개 claim 으로 충분하나, "기능적으로도 해법이 아니다" 의 1차 근거는 미아카이브 → §Claims To Verify |
## 구현 가이드
> 본 branch 의 결정(D1·D2·D4·D6·D7)에서 도출되는 구현 detail 만. realm/client 생성(realm-client-export 소유), `aud` 검증(audience-validator 소유), EC2 실배포(D3 로 제외)는 §범위 Out of scope 로 이관 — 본 § 에 남기지 않는다(R3).
> ⚠️ **전 항목 `planned`** — `keycloak-patterns` repo 부재(NO_GROUND_TRUTH). 아래는 *사전 명세*이며 코드로 확인된 사실이 아니다.
### 1. 실패 재현 명세 (의도적 mismatch)
> **Trace**: D4(실패 먼저) + D1(`KC_HOSTNAME` 이 `iss` 를 결정) ← `KC-HOST-C2`. 관찰 대상 메커니즘은 `SSRS-JWT-C2`(discovery 4단계의 4번 = `iss` 를 `issuer-uri` 와 비교).
>
> - **UNSUPPORTED_IMPL_DECISION**: 아래 "재현 조건" 의 구체 조합(`KC_HOSTNAME` 제거 + `KC_HTTP_ENABLED=true` 만)은 공식 문서가 *권고하는 구성*이 아니라 **함정을 만들기 위한 의도적 오구성**이다. `KC-HOST-C2` 는 hostname 설정을 의무화할 뿐 "미설정 시 무엇이 일어나는지" 는 명시하지 않는다(hostname guide §Usage Boundaries 가 스스로 미증명이라고 기록) — 재현 결과는 실측으로만 확정. trade-off: 학습 목적상 *공식이 금지한 구성*을 일부러 만드는 것이 이 branch 의 가치.
| 항목 | 명세 | 근거 |
|---|---|---|
| Keycloak 설정 | `KC_HOSTNAME` **미설정**, `KC_HTTP_ENABLED=true` 만 | D4 재현 조건 (의도적 오구성) |
| backend 설정 | `spring.security.oauth2.resourceserver.jwt.issuer-uri: http://keycloak:8080/realms/keycloak-patterns` (Docker 내부 DNS — 함정의 원인) | `DOCKER-COMPOSE-NET-C2` (서비스명 DNS 는 도달됨 → *네트워크는 성공하고 검증만 실패*하는 것이 이 함정의 교육 포인트) |
| 관찰점 1 | backend 응답: `GET /api/me`**401** | D4 |
| 관찰점 2 | backend 로그의 예외 **클래스명 + 메시지 verbatim 캡처**. ⚠️ **실패가 어느 단계에서 나는지가 미확정**`SSRS-JWT-C2` 의 discovery 4단계 중 **(a) 1단계**(`keycloak:8080` 로 Provider Configuration 조회 → 이 호출은 `DOCKER-COMPOSE-NET-C2`**성공**하고, 돌아온 메타데이터의 `issuer` 필드가 설정한 `issuer-uri` 와 달라서 실패) 인지 **(b) 4단계**(token `iss``issuer-uri` 와 비교) 인지 아카이브된 claim 이 규정하지 않음. **단계가 다르면 예외 클래스와 교육 포인트가 통째로 바뀐다** (1단계 실패라면 F 의 정당성은 오히려 강화 — F 는 그 단계를 건너뜀). 예외 **클래스명으로 판별**할 것 | §Claims To Verify 1행 + 신규 "실패 단계 판별" 행 |
| 관찰점 3 | jwt.io 로 decode 한 access_token 의 `iss` 실측값 | §Claims To Verify 2행 |
| 대조 | 관찰점 3(token 의 `iss`) ≠ backend `issuer-uri` 임을 **두 문자열 나란히** 기록 | D5 (원리) |
### 2. 해결 경로 4종 설정 명세 (사전 — 실측 전)
> **Trace**: D6(메커니즘 선택) + D2(4종 모두 학습). 각 행의 근거 claim 은 아래 표 `근거` 열.
>
> - **UNSUPPORTED_IMPL_DECISION**: realm 명 `keycloak-patterns`는 프로젝트 F3에서 상속한다. JWKS 경로 문자열은 runtime `.well-known/openid-configuration`의 `jwks_uri`로 재확인해야 하므로 현재 `needs-confirmation`이다. F는 2026-07-18 기본으로 채택했지만 실제 401→200과 key rotation 결과 전까지 `planned`다.
| # | Keycloak 측 | backend 측 (`application.yml`) | Docker 측 | `iss` 일치? **(이론 — 실측 전)** | 근거 |
|---|---|---|---|---|---|
| **A** | `KC_HOSTNAME=localhost` | `issuer-uri: http://host.docker.internal:8080/realms/keycloak-patterns` | `extra_hosts: ["host.docker.internal:host-gateway"]` | ❌ **FAIL** — token `iss``localhost` 기준인데 기대값은 `host.docker.internal` | `DOCKER-COMPOSE-NET-C3`,`C4` (메커니즘), `SSRS-JWT-C1` (issuer-uri 는 iss 값이어야 함 → 불일치 확정) |
| **B** | `KC_HOSTNAME=localhost` | `issuer-uri: http://localhost:8080/realms/keycloak-patterns` | `network_mode: host` (backend) | ✅ PASS | `DOCKER-HOSTNET-C1` (Linux native / Desktop 4.34+ opt-in), `DOCKER-HOSTNET-C4` (**`ports:` 무시** — compose 재구성 필요), `DOCKER-HOSTNET-C3` (Windows 컨테이너 불가) |
| **C** | `KC_HOSTNAME=localhost` | `issuer-uri: http://localhost:8080/realms/keycloak-patterns` (무변경) | `extra_hosts: ["localhost:host-gateway"]` (backend) | ✅ PASS (조건부 — `/etc/hosts` 중복 우선순위 실측 필요) | `DOCKER-COMPOSE-NET-C4` (host-gateway), `DOCKER-2010-C1` (20.10+), `DOCKER-COMPOSE-NET-C3``Does not prove` (기존 entry 재매핑 미언급 = 이 행의 리스크) |
| **F (기본)** | `KC_HOSTNAME=localhost` | `issuer-uri: http://localhost:8080/realms/keycloak-patterns` **+** `jwk-set-uri: http://host.docker.internal:8080/realms/keycloak-patterns/protocol/openid-connect/certs` | backend `extra_hosts: ["host.docker.internal:host-gateway"]` | ✅ PASS 예상 | `SSRS-JWT-C5`, `DOCKER-COMPOSE-NET-C3/C4` |
**왜 F가 함정을 없애는가 (D6)**: `iss` 문자열 검증과 JWKS fetch 주소를 분리한다. 단 Docker dev에서 `host.docker.internal` 도달을 위해 Linux의 `extra_hosts`는 여전히 필요하지만, token `iss`를 network alias로 바꾸지는 않는다.
### 3. 검증 관측점 (해결 후)
> **Trace**: D6(어느 경로든 동일 기준으로 판정) + D4(before/after 대조가 학습 산출물). 프로젝트 §Branch 분해표의 본 branch 목표 조건("`KC_HOSTNAME` 미설정 → `iss` mismatch `401` 재현 → 설정으로 해결(**로그 before/after**)")과 정합.
| 관측점 | 기대 | 캡처 형태 |
|---|---|---|
| `GET /api/me` | 401 → **200** | 응답 상태 + 본문 |
| backend 로그 | `iss` 관련 예외 **소멸** | before/after 로그 발췌 |
| token `iss` vs `issuer-uri` | **문자열 동일** | 두 값 나란히 |
| (F 한정) discovery 호출 부재 | backend 가 `localhost:8080/.well-known/...`**호출하지 않음**`SSRS-JWT-C5` 의 "will not ping" 실측 | 네트워크 로그 또는 Keycloak access log |
## 엣지·실패·의존
- **실패·엣지 경로**:
- **`KC_HOSTNAME` 미설정 시 Keycloak startup 동작** — 실패하는지 vs Host 헤더로 추측하는지 **미확정**. hostname guide §Usage Boundaries 가 "hostname 미설정 시의 정확한 startup 동작은 본 인용에 없음"(`KC-HOST-C2``Does not prove`)이라고 명시. 재현 시나리오 자체가 이 동작에 의존하므로 **실패 재현이 의도대로 안 될 수 있음**(startup 자체가 죽으면 401 이 아니라 서비스 부재).
- **(C) `/etc/hosts` 중복 entry — fallback 비교군** — base 이미지의 `127.0.0.1 localhost``extra_hosts: ["localhost:host-gateway"]` 우선순위가 미확정이라 기본에서 제외했다. 비교 실험 시 `getent hosts localhost`로 확인한다.
- **실습 순서 권고**: `getent hosts localhost` 확인을 **초반에** 배치 — 기본 경로의 성립 여부가 나머지 계획을 좌우하므로.
- **(B) `ports:` 무시 → compose D6 무효화** — `DOCKER-HOSTNET-C4`: host network mode 에선 `-p`/`ports:`**경고만 내고 무시**된다. 깨지는 구체 계약은 [[raw/branch-notes/feature-keycloak-docker-compose-stack]] **D6**(`keycloak 8080:8080`, `app 8081:8081`, `nginx 80:80`) — B 를 backend 에 켜면 `app 8081:8081` 게시가 무효가 되어 브라우저의 backend 직접 접근이 조용히 깨진다.
- **(B) Docker Desktop 게이트** — `DOCKER-HOSTNET-C1`/`C2`/`C5`: 4.34 미만이면 아예 미지원, 이상이어도 **수동 활성화 + layer 4 한정**. macOS/Windows 개발자는 재현 불가할 수 있음.
- **(F) JWKS key rotation** — `jwk-set-uri` 수동 지정 시 Keycloak 이 서명 키를 rotate 하면 캐시 갱신이 discovery 경로와 동일하게 동작하는지 미확정(`SSRS-JWT-C2` 가 retry/backoff 를 범위 밖으로 명시) → 장기 실행 시 401 재발 가능.
- **EC2 확장 시 hairpin NAT** — Docker dev의 `host.docker.internal` profile을 EC2에 그대로 적용하지 않는다. EC2/prod의 JWKS network address는 배포 topology owner가 별도로 결정해야 한다(`needs-confirmation`).
- **토큰 만료·clock skew** — 본 branch 범위 밖(`iss` 만 다룸). `exp`/`nbf` 검증 실패를 `iss` 함정으로 오진하지 않도록 실패 재현 시 예외 **클래스명까지** 확인할 것.
- **다른 계약 의존** (대상 브랜치 + Decision ID 입도):
- [[raw/branch-notes/feature-keycloak-single-ec2-no-google]] D3 — 최종 Docker dev wiring owner. 2026-07-18 sync에서 F(issuer/JWKS 분리)를 기본으로 정렬했다.
- [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D6 — Spring RS wiring owner. `issuer-uri` + explicit `jwk-set-uri` profile로 정렬하며 key rotation cache는 `needs-confirmation`이다.
- [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] **D5** — 그 D5("JWKS cache 기본 정책 5분, kid mismatch 시 자동 refresh", `UNSUPPORTED_DECISION`)는 본 branch D6 의 F Open Risk("`jwk-set-uri` 수동 지정 시 key rotation 캐시 갱신 미확인")와 **동일한 미지수**다. 두 노트가 같은 공백을 각자 들고 있음 — 해소 시 공동 처리.
- [[raw/branch-notes/feature-keycloak-docker-compose-stack]] **D3** — ⚠️ **F 가 이 결정의 트리거 조건을 소거한다.** 그 D3(`depends_on: condition: service_healthy`)의 선택 조건은 "**app 이 startup 시 keycloak JWKS/issuer discovery 에 의존할 때 이 결정**" 인데, `SSRS-JWT-C5` 는 F 하에서 RS 가 "will not ping the authorization server at startup" 이라 하고 `jwk-set-uri` 의 사용 동기 자체가 "initialize independently from the authorization server" 다 → **F 채택 시 D3 의 근거가 약화**(첫 요청 시점 도달성만 필요). D3 owner 에게 전파 필요.
- [[raw/branch-notes/feature-keycloak-docker-compose-stack]] **D6** — port 매핑(`keycloak 8080:8080`, `app 8081:8081`, `nginx 80:80`)이 본 branch §구현 가이드 §2 표의 **모든 `:8080`** 과 F 의 `jwk-set-uri` 포트의 출처. **서비스명이 `keycloak` 이 아니게 되거나 포트가 바뀌면 F 의 `jwk-set-uri` 와 A/C 의 `extra_hosts` 대상이 함께 깨진다.** 해결 (B) 는 이 D6 을 무효화(위 실패·엣지 참조).
- [[raw/branch-notes/feature-keycloak-docker-compose-stack]] D5 — localhost identity를 구현하는 Compose consumer. 값 변경 권한은 parent D3에 있고 본 branch D1은 실패 시연만 소유한다.
- [[raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce]] **D1** — 프로젝트 §Branch 분해표가 본 branch 의 선행 의존으로 지정. 그 D1(`oidc-client-ts` 우선 채택)이 token 을 실제 발급받는 경로 → **token 이 없으면 `iss` 를 관찰할 수 없다**(재현 자체가 불가).
- [[raw/branch-notes/feature-keycloak-realm-client-export]] — realm `keycloak-patterns` 존재가 `iss` 문자열(`.../realms/keycloak-patterns`)의 전제. 프로젝트 **F3**(단일 공유 realm `keycloak-patterns`)이 SSOT.
## 검증해야 할 주장
> (2026-07-17) 자동조사로 판정된 3건은 Status 를 `resolved-by-source` 로 갱신하고 판정 내용을 §Audit & Findings 에 기록. 나머지는 **실측으로만** 닫힌다.
| Claim | Why uncertain | How to verify | Status |
|---|---|---|---|
| `KC_HOSTNAME=localhost` 미설정 + `KC_HTTP_ENABLED=true` 만으로 backend 가 정확히 `JwtValidationException` / `iss claim did not match` 메시지를 로그에 남기는지 | Spring Security 6.x 의 정확한 에러 메시지 텍스트는 버전마다 다를 수 있음 | docker-compose 로 환경 띄우고 backend 로그 캡처, 메시지 verbatim 기록 | `planned` |
| Decoded access_token 의 `iss` claim 이 정확히 `http://localhost:8080/realms/keycloak-patterns` 형태로 박히는지 | Keycloak 26.x 의 `iss` 생성 규칙은 `KC_HOSTNAME` + realm path 결합이라 가정 — verbatim Source 부재 (hostname guide §Usage Boundaries 가 "정확한 string concatenation 은 본 페이지에 명시 없음" 으로 스스로 기록) | jwt.io 로 token decode 후 `iss` 값 캡처 | `needs-confirmation` |
| 해결 (A) `KC_HOSTNAME=localhost` + backend `extra_hosts: ["host.docker.internal:host-gateway"]` 조합이 실제 e2e 로 401 → 200 으로 전환되는지 | 본 branch 본문 자체가 "issuer-uri 와 token iss mismatch" 함정을 재인지함 — 정답은 "token issuer 와 backend issuer-uri 를 정확히 일치". **2026-07-17: `SSRS-JWT-C1` 기준 A 원안은 이론적으로 FAIL 로 판정** — 실측은 "실패함" 을 확인하는 대조군 | docker-compose 환경 구성 후 GET /api/me 200 응답 확인 (**200 이 나오면 오히려 이론 판정이 틀린 것 → 재조사**) | `planned` |
| `network_mode: host` 가 macOS/Windows Docker Desktop 에서 동작 안 함 + Linux only 진술의 정확한 vendor 출처 | 본 branch 본문 메모 — 직접 source 인용 부재 | Docker 공식 doc (`network_mode` 페이지) 또는 Docker Desktop release note 정독 | **`resolved-by-source` (2026-07-17)** — **부분 refute**. [[raw/official-docs/docker-host-network-driver-official]] `DOCKER-HOSTNET-C1`/`C2`: Linux native + **Docker Desktop 4.34+ 에서 opt-in 지원**(Settings 수동 활성화). "Linux only" 는 무조건 진술로는 부정확. 단 `C5`(layer 4 한정) + `C3`(Windows 컨테이너 불가)로 제약은 실재 |
| `extra_hosts: host-gateway` 의 최소 Docker 버전 (20.10+) 의 정확한 source | 본 branch 본문 메모 — verbatim source 부재 | Docker Compose 공식 spec 또는 docker engine release note 확인 | **`resolved-by-source` (2026-07-17)** — **부분 confirm**. [[raw/official-docs/docker-engine-20-10-release-notes-official]] `DOCKER-2010-C1`: `host.docker.internal` 의 Linux dockerd 지원은 **20.10.0(2020-12-08)** 확정. 단 `host-gateway` **리터럴 자체**의 도입 버전은 이 release notes 페이지로 미확정(문자열이 20.10.23 버그수정 항목에만 등장) → 완전 확정하려면 moby/moby#40007 원문 필요 |
| Keycloak 26.x 에서 `KC_HOSTNAME_BACKCHANNEL_DYNAMIC` 옵션이 실제 frontchannel/backchannel URL 을 자동 분리하는지 | `KC-HOST-C1`/`C4` 가 capability 자체는 증명하나, 본 프로젝트 단일 host 시나리오에서의 실 동작은 별도. **2026-07-17 조사도 공식 근거를 못 찾음** — hostname guide(v2) 본문에 `issuer` 라는 단어 자체가 없음. v1 문서(24.0.5)에는 "the issuer is also based on the URL set to the frontend endpoints" 가 있었으나 v2 가 재확인하지 않음 | docker-compose 환경에 옵션 추가 후 **두 경로에서 각각** `.well-known/openid-configuration` 호출해 `issuer` 값 비교. `KC_HOSTNAME_DEBUG=true` + `/realms/master/hostname-debug` 병행 권고 | `planned` |
| Keycloak 24+ 에서 26.x 까지 `KC_HOSTNAME_*` 옵션 명칭이 자주 바뀐다는 본문 진술 | 본 branch 본문 메모 — Source 부재 | Keycloak release notes (24, 25, 26) 정독, 옵션 rename 이력 정리 | **`resolved-by-source` (2026-07-17)** — **부분 confirm / 프레이밍 refute**. `KC-2500-C1`~`C4` + `KC-2600-C1`: 개편은 실재하나 "26.x 안에서 자주" 가 아니라 **25.0.0 에서 v2 도입(v1 deprecated) → 26.0.0 에서 v1 제거** 의 **1회 대개편**이며 26.x point release 간에는 안정적. 정확한 진술: "한 번 크게 바뀌었고 그 시점은 26.0 이전에 종료" |
| `iss` 검증을 안 하면 다른 Keycloak realm 또는 가짜 IdP token 통과하는지 의 실 재현 | 본 branch 본문 메모. `KC-HOST-C3` 는 일반 rationale 만 — 가짜 IdP 시나리오 직접 증명 안 함 | 두 번째 Keycloak realm 또는 미니멀 fake JWT issuer 띄우고 token 발급 → backend 가 거부하는지 확인 (현재 hostname-strict + audience validator 와 결합) | `planned` |
| (신규 2026-07-17) 해결 (C) 에서 `extra_hosts: ["localhost:host-gateway"]` 가 base 이미지의 기존 `127.0.0.1 localhost` entry 를 실제로 이기는지 | Docker 공식이 이 케이스(기존 hostname 재매핑)를 명시 안 함 — `DOCKER-COMPOSE-NET-C3``Does not prove` 가 경계를 기록 | 컨테이너 내부 `getent hosts localhost` 로 실제 resolve IP 확인 | `needs-confirmation` |
| (신규 2026-07-17) 해결 (F) 에서 `jwk-set-uri` 수동 지정 시 Keycloak key rotation 과의 캐시 갱신 상호작용 | `SSRS-JWT-C2` 가 "discovery 실패 시 retry/backoff 정책은 범위 밖" 으로 명시 — rotation 시 JWKS 재fetch 정책 불명 | [[raw/official-docs/jwks-keycloak-key-rotation-active-passive]] 정독 + F 조합에서 키 rotate 후 401 재발 여부 실측 | `needs-confirmation` |
| (신규 2026-07-17) JWKS endpoint 경로 `/realms/<realm>/protocol/openid-connect/certs` 가 26.x 의 실제 값인지 | 본 branch Sources 중 어느 것도 이 경로 문자열을 증명하지 않음 (구현 가이드 §2 의 `UNSUPPORTED_IMPL_DECISION`) | `.well-known/openid-configuration``jwks_uri` 필드 실측값으로 확정 | `needs-confirmation` |
| (신규 2026-07-17) "hostname 설정 시 hostname-strict 무시 / hostname 미설정 시 backchannel-dynamic 강제 false" Validations 규칙 + password-reset 링크 조작 공격 시나리오 | 조사가 hostname guide(v2) 원문에서 확보했으나 **아직 아카이브 안 됨** — D7 의 "기능적으로도 해법이 아니다" 논거의 1차 근거 | 현행 [[raw/official-docs/keycloak-hostname-configuration]] 에 `KC-HOST-C6` 로 추가 아카이브 (기존 파일 갱신 — 신규 파일 아님) | `needs-confirmation` |
| (신규 2026-07-17, **depth 감사 F4**) 실패 재현 시 401 이 discovery **1단계**(메타데이터 `issuer` 필드 대조)에서 나는지 token `iss` 검증 **4단계**에서 나는지 | `SSRS-JWT-C2` 는 4단계를 나열할 뿐 *어느 단계가 mismatch 를 먼저 잡는지* 규정 안 함(그 claim 의 `Does not prove` 는 retry/backoff 만 배제). §구현 가이드 §1 재현 구성은 `issuer-uri: http://keycloak:8080/...` 이라 **discovery 호출 자체는 성공**한다 → 실패 지점이 두 후보로 갈림 | backend 로그의 **예외 클래스명**으로 판별 (discovery 단계 실패면 `JwtDecoderInitializationException` 계열, `iss` 검증 실패면 `JwtValidationException` 계열로 *추정* — 실측으로 확정). 필요 시 Spring `§Startup Expectations` 잔여 문단을 기존 raw 에 추가 아카이브 | `needs-confirmation` |
| (신규 2026-07-17, **depth 감사 F7**) `SSRS-JWT-C5` 의 "will not ping … **at startup**" 이 first-request 시점 discovery 까지 배제하는지 | `SSRS-JWT-C2` 는 discovery 가 "at the **first request** containing a JWT" 에 시작된다고 함 → "startup 에 안 한다" 가 "영원히 안 한다" 를 verbatim 으로 닫지는 않음. §제목("… JWK Set Uri **Directly**") + "Consequently" 인과 구조상 discovery 자체를 건너뛴다는 독해가 자연스러우나 명시 아님 | §구현 가이드 §3 의 "(F 한정) discovery 호출 부재" 관측점으로 실측. 부수적으로 `spring-security-resource-server-jwt.md``SSRS-JWT-C5` `Does not prove` 열에 이 경계 1줄 추가 권고 | `needs-confirmation` |
## Audit & Findings (2026-07-17 `/branch-spec` 자동조사)
> 본 § 는 조사 결과 중 **결정으로 흡수되지 않은 발견·정합 권고**만 보존 (CLAUDE.md §15.5 R3 — 이관 history 는 별도 § 에).
| ID | 유형 | 발견 | 조치 |
|---|---|---|---|
| **A1** | `MISSING_EVIDENCE_LINK` (해소됨) | [[raw/official-docs/spring-security-resource-server-jwt]] 는 **이미 이 repo 에 아카이브돼 있었고** `SSRS-JWT-C5` 가 D6 의 정답을 담고 있었으나, 본 branch 의 Sources 표에 링크되지 않아 해결 방안이 Docker 레이어(A/B/C)로만 좁혀져 있었다. 노트의 §진행 중 메모("backend 가 **어디서 JWKS 를 fetch 하든**")는 이미 원리를 알고 있었으나 해결 방안에 반영되지 않음 | 2026-07-17 Sources 표에 정식 링크 + D6 신설 + In scope 에 F 추가 (**해소**) |
| **A2** | `CROSS_BRANCH_DECISION_TENSION` | parent D3과 본 D6가 Docker layer vs issuer/JWKS split을 달리 가리켰음 | **해소 (2026-07-18)** — parent D3을 owner로 유지하고 F profile을 기본으로 정렬. 본 D1은 실패 시연 범위만 소유 |
| **A7** | `CROSS_BRANCH_DECISION_CONFLICT` | audience-validator D6의 discovery-only wiring과 본 D6의 explicit `jwk-set-uri`가 충돌했음 | **해소 (2026-07-18)** — Spring RS owner도 explicit `jwk-set-uri` Docker dev profile을 허용하도록 정렬. key rotation cache는 runtime `needs-confirmation`으로 남김 |
| **A8** | `TRIGGER_CONDITION_ERODED` (**미해소 — depth 감사 F3 발견**) | [[raw/branch-notes/feature-keycloak-docker-compose-stack]] **D3**(`depends_on: condition: service_healthy`)의 선택 조건은 "**app 이 startup 시 keycloak JWKS/issuer discovery 에 의존할 때**" 인데, `SSRS-JWT-C5`("will not ping … at startup" + "initialize **independently from** the authorization server")에 따라 **F 는 그 트리거 조건을 약화**시킨다(첫 요청 시점 도달성만 필요) → **완전 소거 여부는 F7 확정에 종속**`SSRS-JWT-C5` 의 "at startup" 이 first-request 시점 discovery 까지 배제하는지가 `needs-confirmation` 이므로, 현재 정확한 강도는 "**약화**"다 | 본 branch 결정 아님(owner = docker-compose-stack). §엣지·실패·의존에 명시 완료. **F 승인 시 D3 owner 에게 전파 필요** — healthcheck 를 유지할지(다른 이유: PostgreSQL 의존 등)는 그 branch 판단 |
| **A3** | `DOC_DRIFT` (부모 노트, 미해소) | 부모 노트 **D5** 는 "realm 1개 + client 1개(`spa-client`, public)" 라고 적었으나, 프로젝트 노트 **F3**(고정 결정, SSOT)은 "단일 공유 realm `keycloak-patterns`, 패턴당 client 1개 — **`spa-public`** / token-mediating-confidential / bff-confidential / edge-proxy" 로 client 명이 다름 | 본 branch 범위 밖(부모 노트 소유). 본 branch 는 realm 명 `keycloak-patterns`(F3 정합)만 사용하고 client 명은 참조 안 함. `/sync` 대상으로 보고 |
| **A4** | `ALTERNATIVE_REJECTED` | 조사가 5번째 대안 **E — `network_mode: "service:<anchor>"`** (더미 anchor 컨테이너의 network namespace 를 Keycloak+backend 가 공유 → 컨테이너 안에서 `localhost:8080` 이 문자 그대로 동작, 플랫폼/버전 게이트 **없음**)를 발견. iss 판정 PASS | **채택 안 함** — anchor 컨테이너가 죽으면 두 서비스 네트워크 전체가 죽고, 공유 서비스의 모든 포트를 anchor 의 `ports:` 에 나열해야 함. 서비스 2개 규모에 과설계. §범위 Out of scope 에 기록. 서비스 3개+ 가 동일 `localhost` identity 를 요구하면 재검토 |
| **A5** | `DROPPED_CANDIDATE` | 조사가 "컨테이너 IP 를 `docker inspect` 로 확인해 browser/backend 모두 그 IP 로 접속" 안을 탈락시킴 — `docker compose up` 재기동마다 IP 가 바뀌어 `issuer-uri`/`KC_HOSTNAME` 고정 불가(재현성 없음) | 기록만. 실습 불필요 |
| **A6** | `UNARCHIVED_EVIDENCE` | 조사가 확보한 hostname guide(v2) **Validations 규칙**("hostname 설정 시 hostname-strict 무시" / "hostname 미설정 시 backchannel-dynamic 강제 false")과 **password-reset 링크 조작 공격 시나리오** verbatim 은 D7 의 핵심 논거이나 **아직 아카이브 안 됨**. 기존 [[raw/official-docs/keycloak-hostname-configuration]] 의 신규 claim(`KC-HOST-C6`) 후보 — 신규 파일이 아니라 **기존 파일 갱신**이라 `wiki-source-summarizer` 계약 밖 | §Claims To Verify 에 등록. 다음 세션에 수기 또는 `wiki-doc-author` mode=migrate 로 기존 파일에 추가 권고 |
## 마주친 문제
> ⚠️ (2026-07-17) 아래 3건은 "(구현 시작 후 추가)" 로 적혀 있으나 **구현은 시작된 적이 없다**(repo 부재 — NO_GROUND_TRUTH). 실제로는 *예상 문제 메모*이며, 2026-07-17 자동조사가 공식 문서로 판정했다. 실측 근거가 아니므로 면접에서 "겪었다" 로 말하면 안 된다.
- (구현 시작 후 추가) `network_mode: host` 사용 시 macOS/Windows Docker Desktop에서 동작 안 함 — Linux only.
-**부분 refute** (`DOCKER-HOSTNET-C1`/`C2`): Docker Desktop **4.34+ 에서 opt-in 지원**. "동작 안 함" 은 4.34 미만 또는 미활성 시에 한정. 단 layer 4 한정(`C5`).
- (구현 시작 후 추가) `extra_hosts: host-gateway` 동작이 Docker 버전에 따라 다름 — 최소 Docker 20.10+.
-**부분 confirm** (`DOCKER-2010-C1`): `host.docker.internal` 의 Linux dockerd 지원 = 20.10.0. `host-gateway` 리터럴 자체의 도입 버전은 미확정.
- (구현 시작 후 추가) Keycloak 26.x에서 `KC_HOSTNAME_*` 옵션 명칭이 자주 바뀜 — 공식 문서 버전 확인 필요.
-**프레이밍 refute** (`KC-2500-C1`~`C4`, `KC-2600-C1`): "자주" 가 아니라 **25.0.0 v2 도입 → 26.0.0 v1 제거의 1회 대개편**. 26.x 안에서는 안정적.
## 묶음
<!-- GENERATED: sources:start -->
- [[raw/official-docs/docker-compose-networking-extra-hosts-official]]
- [[raw/official-docs/docker-engine-20-10-release-notes-official]]
- [[raw/official-docs/docker-host-network-driver-official]]
- [[raw/official-docs/keycloak-2500-hostname-v2-release-official]]
- [[raw/official-docs/keycloak-2600-hostname-v1-removed-official]]
- [[raw/official-docs/keycloak-hostname-configuration]]
- [[raw/official-docs/openid-connect-core-id-token-validation]]
- [[raw/official-docs/spring-security-resource-server-jwt]]
<!-- GENERATED: sources:end -->
> 본 sub-sub-branch 는 leaf — 자식 자료 없음. Phase 3 P3A 실 구현 또는 외부 산출물 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
> (2026-07-17) 근거 자료는 §Sources 표가 SSOT — 본 § 에 중복 나열하지 않는다(병행 dispatch 가 만든 `### Sources` 서브섹션 제거).
### 오류 기록 (이 sub-sub-branch 작업 중 발생)
- (없음 — 현재 documented-only 단계)
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
- (없음 — Phase 3 실 구현 단계에 누적)
## 관련 일일 노트
## 완료 후 정리
> 실패 재현 → 해결 검증 전체를 로그/screenshot으로 캡처 시 `planned` → `actually-implemented`/`locally-verified` 승급. 본 sub-sub는 학습 가치가 핵심 — 실제로 함정을 "맞아본" 경험이 면접 자산.
- PR 링크: (별도 keycloak-patterns repo)
- 리뷰 메모:
- 머지 결과 / 배포 환경: 로컬 docker-compose / 단일 EC2 시뮬레이션
- **wiki 추출 대상**:
- `actually-implemented` 항목: (구현 후 채움)
- `locally-verified` 항목: (구현 후 채움)
- `prod-verified` 항목: (없음)
- **추출하지 않을 항목**: 현재 전부 `planned`.