Files
llm-wiki/raw/branch-notes/feature-keycloak-single-ec2-no-google.md

392 lines
45 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: branch / feature-keycloak-single-ec2-no-google (P3A Single EC2 — client + backend + keycloak 동거, no Google)
source_type: branch-note
status: raw
id: BR-KEYCLOAK-CHILD-FE8F0749
kind: branch-child
project: keycloak-patterns-overview
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-020
inherits: [DEC-KEYCLOAK-PATTERNS-OVERVIEW-AUTH-TAXONOMY-001@1]
refines: []
overrides: []
depends_on: []
contract_packet: 1
branch: feature-keycloak-single-ec2-no-google
parent_branch: feature-keycloak-patterns
related_projects: [keycloak-patterns]
tags: [branch, keycloak-patterns, auth, oauth2, oidc, docker]
created: 2026-05-25
target_merge:
status_label: in-progress
contract_packet_sha256: 930562ffd6f26bab1c938d08a2d2403cdc3627a5b242ffabe05057a32f792e17
---
# branch: feature-keycloak-single-ec2-no-google (P3A Single EC2, no Google)
> Layer: `raw/branch-notes/` — [[raw/branch-notes/feature-keycloak-patterns]]의 WI020 child branch.
> 본 패턴은 6개 패턴 중 **유일하게 vanilla JS로 실 구현**되는 케이스. 나머지 5개(P1A/P1B/P2A/P2B/P3B)는 문서/다이어그램까지만.
> **축 재편 (2026-07-14)**: hub [[raw/project-notes/keycloak-patterns-overview]] §2.3 에서 P3A → **AP1** (Browser-based OAuth Client = SPA-direct + Resource Server) + cross-cutting `배포=single-EC2 (실 구현 base)` 로 re-map 됨. hub 고정 결정 **F5**(§5)가 "E2E 실 구현 배포 = single-EC2 docker-compose **1벌**" 로 확정 → **본 노트는 4 패턴(AP1~AP4) 전체가 얹히는 물리 배포 base** 이다(그 위 실행 단계는 6개 자식이 owner — §구현 가이드 §3). 본문의 "P3A" 프레이밍은 Phase 0 표기이며, rename/re-parent 은 hub §2.3·§8 대로 `wiki-doc-author mode=migrate` 로 점진 수행(§진행 중 메모 `AXIS_DRIFT`).
<!-- section-id: branch-parent -->
## 부모 (필수)
[[raw/branch-notes/feature-keycloak-patterns]]
<!-- GENERATED: branch-contract:start -->
<!-- section-id: branch-contract-packet -->
## 브랜치 계약 패킷
- **생성 시 프로젝트 개정**: `1`
- **패킷 스키마**: `contract_packet: 1`
- **완료 조건**: project governance hub가 AP1~AP4 taxonomy와 child progress index를 유지한다
<!-- section-id: inherited-project-decisions -->
### 상속한 프로젝트 결정
| Decision Ref | Project Summary | Branch Application | Source |
|---|---|---|---|
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-AUTH-TAXONOMY-001@1` | canonical 분류축은 AP1~AP4 인증 통합 아키텍처와 cross-cutting 변형이다 | single-EC2를 AP1~AP4가 공유하는 deployment cross-cutting 변형으로 분류한다 | [[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 호스트에 **client (vanilla JS SPA via nginx static)** + **backend (Spring Boot)** + **Keycloak** 세 컴포넌트를 동거시킨 상태에서, OIDC Authorization Code + PKCE 흐름이 실제로 어떻게 동작하는지 코드 레벨로 학습. 토큰 교환 흐름은 P2A와 동일(Browser → Keycloak → Backend Resource Server JWT validation). 차이는 **네트워크 토폴로지**와 **`KC_HOSTNAME` 함정**.
면접에서 "OIDC 전체 lifecycle을 직접 구현해 봤다 → access/refresh/ID token 차이, PKCE 필요 이유, JWT issuer 검증 메커니즘을 코드로 설명 가능"이 목표.
<!-- section-id: branch-scope -->
## 범위
### 포함 범위
- **단일 EC2 배포 토폴로지 확정 (본 branch 고유 소유)** — nginx(SPA static) + Spring Boot(Resource Server) + Keycloak + PostgreSQL 를 **단일 호스트 docker-compose 로 동거**시키는 물리 경계·포트 노출·localhost trust 확정(D3, hub F5). hub 재편 후 **4 패턴(AP1~AP4) E2E 가 모두 이 배포 base 위에 얹힌다**.
- **`KC_HOSTNAME` iss 함정의 배포측 정의** — 단일 host 에서 browser 와 backend 가 같은 issuer hostname 을 봐야 하는 이유·해결 축 확정(D3). 재현·해결 절차 자체는 자식 [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] 소관.
- **HTTPS-less 학습 경계 확정** (D1) — 학습 환경은 HTTP, prod 진입 시 Caddy / nginx + Let's Encrypt 로 termination 추가.
- **vanilla JS 로 OIDC lifecycle 을 실제로 구현하는 유일 케이스** — 6 실 구현 단계(자식)로 분해(§Cluster), 각 단계가 `planned` → 구현 후 `locally-verified` 승급.
- **문서 산출물** — 컴포넌트 토폴로지 · 토큰 교환 sequence · 단일 호스트 특이점(`KC_HOSTNAME`/`redirect_uri`) · 장단점 (모두 현재 `planned`/`documented-only`).
### 제외 범위
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
- **실행 코드 detail** — 본 노트는 6 단계의 **통합 배포 base** 이며 detail 은 재진술하지 않고 위임한다(Reference-Only, §구현 가이드 §3): docker-compose 서비스 정의 → [[raw/branch-notes/feature-keycloak-docker-compose-stack]] D1~D6, realm/client 설정+export → [[raw/branch-notes/feature-keycloak-realm-client-export]] D1~D5, SPA PKCE 코드 → [[raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce]] D1~D5, Spring RS 공통 셋업·audience 검증 → [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1/D6, RBAC → [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] D4~D6, iss 재현·해결 → [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] D1/D4/D6, refresh rotation+logout → [[raw/branch-notes/feature-keycloak-refresh-rotation-and-logout]] D1/D5.
- **Google IdP federation** — [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]] (P3B) 소관. 본 노트는 no-google base.
- **cluster-internal / edge 배포의 별도 구축** — hub F5 에 의해 문서만(hostname·issuer·network 차이). 실 구축(k8s / Traefik)은 안 함.
- **AP2/AP3/AP4 인증 아키텍처 자체의 정의** — 본 노트는 *배포 base* 이지 그 패턴 hub 가 아니다. AP1 pattern hub 는 [[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]] (P2A).
- **RBAC 인가 (keycloak role → Spring `@PreAuthorize`)** — hub §5 deferred(authZ). [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] 의 role→role 부분이 여기로 이월.
- **prod 배포 / HA cluster / 실제 HTTPS 구성** — 전부 `planned`. 본 회차 범위는 로컬 docker-compose(또는 단일 EC2) 학습 검증까지.
## 근거 (필수, 최소 1개+)
> 본 sub-branch의 P3A (Single EC2, 실 구현 대상) 채택 근거. 상세 대안 비교는 §외부 근거 / 대안 조사 참조.
| Source | 정당화하는 결정 |
|---|---|
| [[raw/official-docs/keycloak-server-containers-docker]] | Keycloak Docker container 공식 — docker-compose 채택 근거 |
| [[raw/official-docs/keycloak-hostname-configuration]] | Keycloak hostname guide — KC_HOSTNAME 설정 근거 |
| [[raw/official-docs/keycloak-getting-started-docker]] | Docker quickstart — 단일 host 학습 구성 근거 |
| [[raw/official-docs/spring-security-resource-server-jwt]] | Spring Security Resource Server JWT 검증 — backend Resource Server 근거 |
| [[raw/official-docs/oauth2-pkce-rfc-7636]] | RFC 7636 PKCE — public client 필수 PKCE 근거 |
| [[raw/official-docs/oidc-client-ts-library]] | oidc-client-ts — vanilla JS OIDC client 라이브러리 선택 근거 |
## 외부 근거 / 대안 조사 (2026-05-25 — P3A Single EC2)
본 sub-branch의 **단일 EC2 (client + backend + keycloak 동거) + Authorization Code + PKCE** 채택에 대한 외부 source. P2A를 단일 호스트로 압축한 형태 + 단일 호스트 고유 함정.
- **채택 결정 (Single Host Docker Compose + PKCE + Keycloak `KC_HOSTNAME`)**:
- [[raw/official-docs/keycloak-server-containers-docker]] — Keycloak Docker container 공식 (KC_* 환경 변수)
- [[raw/official-docs/keycloak-hostname-configuration]] — Keycloak hostname guide (iss claim validation 함정)
- [[raw/official-docs/keycloak-getting-started-docker]] — Docker quickstart (단일 host 학습용)
- [[raw/official-docs/spring-security-resource-server-jwt]] — Spring Security Resource Server JWT 검증
- [[raw/official-docs/oauth2-pkce-rfc-7636]] — RFC 7636 PKCE (public client 필수)
- [[raw/official-docs/oidc-client-ts-library]] — oidc-client-ts (vanilla JS OIDC client 라이브러리 선택지)
- **검토한 대안**:
- **대안 1: Cluster 배치 (P2A)** — Kubernetes 또는 ECS로 분리 배치. 장: prod-like / 단: 학습 friction 큼 (네트워크 / DNS / cert 모두 관리). 비교 sub-branch: [[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]].
- **대안 2: Edge ForwardAuth on Single Host** — nginx + oauth2-proxy + Keycloak + backend 모두 단일 host. 장: P1A 학습 가능 / 단: vanilla JS SPA 흐름 학습이 주 목적과 어긋남 (proxy가 인증 처리, SPA는 token 모름).
- **대안 3: BFF on Single Host** — Spring Boot이 Keycloak token holder. 장: 보안 우월 (token이 SPA에 없음) / 단: vanilla JS의 OIDC 학습 목적과 어긋남 (SPA가 session cookie만 사용).
- **대안 4: Direct host (no Docker)** — Keycloak + Spring Boot + nginx를 EC2에 직접 설치. 장: docker overhead 0 / 단: 환경 reset 어려움, 학습 반복 비용 큼.
- **대안 5: 사전 빌드 이미지 (Keycloak Helm + Spring Boot Image)** — managed Keycloak. 학습 단계엔 과함.
- **비교 핵심**: 단일 EC2 + Docker Compose는 **OIDC 전체 lifecycle을 가장 작은 surface로 학습**. `KC_HOSTNAME` 미설정 시 `iss` claim mismatch가 단일 host의 **가장 흔한 함정** — browser는 `localhost:8080`, backend는 Docker internal `keycloak:8080` 보면서 JWT issuer가 mismatch → JWT validation 실패. 해결: `KC_HOSTNAME=localhost` + `KC_HTTP_ENABLED=true` 명시. **redirect_uri는 localhost vs 127.0.0.1 한 글자만 달라도 mismatch** → Keycloak client 등록 시 두 URI 모두 등록 또는 사용 일관화. PKCE는 public client에 필수 (RFC 7636) — `code_verifier` 생성 + `code_challenge=SHA256(verifier).base64url`. HTTPS 없이 학습 환경 한정 — prod 진입 시 Caddy 또는 nginx + Let's Encrypt 필수.
## TODO
각 항목 옆에 증거 등급. **현재 모두 `planned`** — 실 구현 후 별도 작업에서 `actually-implemented`/`locally-verified`로 승급.
- [ ] docker-compose.yml 작성 (keycloak + postgres + spring + nginx) — `planned`
- [ ] Keycloak realm/client 설정 + JSON export — `planned`
- [ ] Spring Boot Resource Server (`/api/me` endpoint with `@AuthenticationPrincipal Jwt`) — `planned`
- [ ] vanilla JS SPA (login button → PKCE 생성 → callback → token storage → `/api/me` 호출) — `planned`
- [ ] iss mismatch issue 재현 + 해결 (`KC_HOSTNAME=localhost` vs `keycloak` 시연) — `planned`
- [ ] refresh_token rotation 시연 (Keycloak `Revoke Refresh Token` 옵션 toggle) — `planned`
- [ ] HTTPS 없는 환경에서 token 노출 demonstration (Wireshark/curl로 헤더 캡처) — `planned`
- [ ] (선택) Caddy reverse proxy로 HTTPS 추가 — `planned`
## 진행 중 메모
작업하며 떠오른 메모. 자유 형식.
- **`AXIS_DRIFT` (2026-07-18 `/branch-spec` 확인)** — hub 가 2026-07-14 에 분류 primary 축을 *배치×federation 6패턴**인증 아키텍처 4패턴(AP1~AP4)* 로 교정([[raw/project-notes/keycloak-patterns-overview]] §2.3)했으나 본 노트 본문은 여전히 Phase 0 의 "P3A" 프레이밍이다. 매핑은 **P3A → AP1 + cross-cutting 배포=single-EC2 (실 구현 base)**. hub §2.3·§8 이 "실제 rename/re-parent 은 `wiki-doc-author mode=migrate` 로 점진 수행(자동 mv 금지 — wikilink 영향 검토)"라고 명시하므로 본 회차에서는 **본문 재작성 없이 정합 표기만** 추가(제목 blockquote + 본 메모). 물리 `parent_branch:` 는 아직 `feature-keycloak-patterns` 이고, AP 그룹 소속은 hub §8 분해표가 SSOT.
- **본 노트는 단일-EC2 실 구현 base — detail 의 owner 는 6 자식이다.** hub F5 가 "실 구현 배포 = single-EC2 1벌" 로 고정하여 본 노트가 그 물리 base 이지만, 실행 단계(docker-compose / realm / SPA / iss / refresh / Spring RS)는 자식 6개가 각각 owner 다(§Cluster). 따라서 본 노트의 `D2`·`D4`·`D5` 는 자식 owner 결정의 *요약*이라 `rules/consistency-contract.md``RESTATED_FOREIGN_DECISION` 소지가 있다 — 사용자 작성 결정을 덮어쓰지 않고 §구현 가이드 §3 에 **위임 맵**을 세워 포인터를 명시한다.
- **`DECISION_DRIFT` 해소 (2026-07-18)** — 과거 parent D4의 manual-first 문구는 **historical/superseded**다. 실제 코딩 순서는 owner [[raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce]] D1의 `oidc-client-ts` library-first이며, manual `crypto.subtle` PKCE는 baseline E2E 뒤의 비교 학습 단계다.
- **`OWNER_SPLIT` (Spring RS + `aud` 검증) — 2026-07-18** — 본 §Cluster 는 [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] 을 나열하나, hub §8 dup-reconciliation 은 "Spring RS 셋업·`aud` 검증 = [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 가 owner, role-mapping 의 role→RBAC 부분만 deferred authZ" 로 정했다. §구현 가이드 §3 위임 맵은 두 owner 를 분리 지정한다(RS 셋업/aud = audience-validator, 본 노트 Cluster 의 role-mapping 은 role→RBAC deferred).
- **repo 부재 (`NO_GROUND_TRUTH` for impl) — 2026-07-18 확인** — `/home/donghyeon/workspace/keycloak-patterns/` 디렉터리가 아직 없다. 따라서 모든 TODO/`구현 계획` 항목은 `planned`(코드로 확인된 `actually-implemented` 아님). 계약 근거는 official docs(Keycloak/Spring/OWASP/RFC)이며, 포트 값 등 배포 상수는 자식 [[raw/branch-notes/feature-keycloak-docker-compose-stack]] D6 이 owner (KC-CONTAINER-C5 가 "포트 값은 공식 raw verbatim 부재" 로 못박음 → 본 노트에서 official 로 단정 금지).
- **`TOPOLOGY_DRIFT` (2026-07-18 depth 감사)** — 본 노트 §컴포넌트 다이어그램·§구현 가이드 §1 은 **3-포트 직노출**(nginx=static only, backend/Keycloak 각자 포트)로 토폴로지를 확정하나, hub [[raw/project-notes/keycloak-patterns-overview]] §3-1-5 다이어그램은 nginx `proxy_pass` 리버스프록시(same-origin)를 그린다. 본 노트가 토폴로지 owner(D3 · hub F5)이므로 divergence 를 공개만 하고 3-포트를 학습 기본으로 유지 — cross-origin 귀결(CORS / Web Origins)은 §구현 가이드 §1 + §엣지·실패·의존 에서 종결한다.
## 컴포넌트 다이어그램
```
EC2 (단일 호스트)
├─ nginx (port 80) → /index.html (vanilla JS SPA static 파일)
├─ Spring Boot (port 8081) → /api/* (Resource Server)
└─ Keycloak (port 8080) → /realms/<realm>/...
Browser → EC2:80 → SPA load
Browser → EC2:8080 → Keycloak (OIDC redirect: /auth → 로그인 → /callback)
Browser → EC2:8081 → Backend (Authorization: Bearer <access_token>)
Backend → EC2:8080/realms/<realm>/protocol/openid-connect/certs (JWKS, localhost network)
```
신뢰 경계: 단일 호스트 내 localhost trust. 외부에서는 EC2 public IP / DNS만 노출.
## 토큰 교환 sequence (P2A와 동일 + localhost 특이점)
1. **SPA: PKCE 생성**`code_verifier` (랜덤 43128 char), `code_challenge = BASE64URL(SHA256(code_verifier))`, `code_challenge_method=S256`. verifier는 `sessionStorage` 저장 (단일 auth 라운드트립 수명 — 콜백 직후 폐기하므로 D2/OWASP 의 *장기 토큰* 저장 금지와는 별개다. 단 `sessionStorage` 자체는 XSS 노출면이라 `raw/official-docs/oauth2-pkce-rfc-7636.md` Usage Boundaries 가 별도 플래그).
2. **SPA → Keycloak `/auth` redirect** — query: `client_id`, `redirect_uri`, `response_type=code`, `scope=openid`, `state`, `code_challenge`, `code_challenge_method=S256`.
3. **사용자 로그인** → Keycloak → `redirect_uri` callback with `?code=...&state=...`.
4. **SPA → Keycloak `/token` (POST form)**`grant_type=authorization_code`, `code`, `redirect_uri`, `client_id`, `code_verifier`. 응답: `access_token` / `refresh_token` / `id_token` / `expires_in`.
5. **SPA → Backend `Authorization: Bearer <access_token>`**.
6. **Backend → Keycloak JWKS** (`localhost:8080/realms/<realm>/protocol/openid-connect/certs`) → public key fetch (캐시) → JWT signature verify + `iss` claim 검증.
## 단일 호스트 특이점
### `KC_HOSTNAME` 함정 (이 패턴의 핵심 학습 포인트)
- `iss` claim은 Keycloak이 발급한 JWT 안에 박힘. 예: `iss=http://localhost:8080/realms/keycloak-patterns`.
- Browser는 `localhost:8080`으로 Keycloak에 접근, backend도 같은 hostname을 issuer-uri로 등록해야 검증 통과.
- Docker Compose에서 backend가 `keycloak:8080`(컨테이너 DNS)로 JWKS를 부르면 issuer mismatch 발생 (token에 박힌 `iss``localhost:8080`인데 backend가 기대하는 issuer가 `keycloak:8080`).
- 해결: backend `issuer-uri = http://localhost:8080/realms/...` 로 통일. JWKS도 같은 hostname으로 부르려면 컨테이너에서 호스트 네트워크 공유(`network_mode: host`) 또는 `extra_hosts: [host.docker.internal:host-gateway]``host.docker.internal` 사용.
- 또는 Keycloak `KC_HOSTNAME_BACKCHANNEL_DYNAMIC=true`로 frontchannel/backchannel URL 분리 (Keycloak 24+).
### redirect_uri mismatch
- Keycloak client 등록 시 `Valid redirect URIs` 정확히 일치해야 함.
- `http://localhost/callback``http://127.0.0.1/callback``http://<ec2-public-ip>/callback`. 셋 다 다른 URI.
- 와일드카드 `http://localhost/*` 허용은 학습 환경 한정. prod 금지.
### 기타
- **PKCE는 여전히 필수** — public client (브라우저는 client_secret 보관 불가).
- **HTTPS 없으면 token 평문 노출** — `access_token`, `refresh_token`이 HTTP 헤더/응답으로 평문 전송. 학습 환경 한정.
- nginx는 단순 static 파일 서빙 (Caddy 또는 nginx + Let's Encrypt로 HTTPS termination 추가 가능).
## 장점 / 단점
### 장점
- **단일 호스트라 네트워크 디버깅 쉬움.** tcpdump / `docker compose logs`로 한 화면에서 추적.
- **docker-compose 한 줄로 환경 reset** (`docker compose down -v && up`).
- **학습 곡선 평탄.** k8s / ingress / Traefik 등 부가 인프라 없음.
- **localhost trust로 보안 변수 최소화** — 외부 노출은 80/8080/8081 세 포트만.
### 단점
- **운영 환경 모방 X.** 실 운영은 Keycloak / API / static 분리 배치(P1·P2 패턴) — 본 패턴은 학습 전용.
- **HTTPS termination 별도 처리 필요.** Caddy reverse proxy를 앞단에 두거나 nginx에 cert 추가.
- **단일 EC2 장애 = 전체 다운.** SPOF.
- **`KC_HOSTNAME` 설정 잘못 시 디버깅 난이도 급증** (issuer/redirect/JWKS URL 3가지가 얽힘).
## 결정 사항 (decisions)
- 2026-05-25: **HTTPS 없이 진행** (학습 환경). prod 진입 시 Caddy 또는 nginx + Let's Encrypt 추가. 이유: cert 발급/갱신 흐름이 본 학습 주제(OIDC)와 무관.
- 2026-05-25: pure SPA의 access/refresh token은 **모두 memory-only**로 둔다. reload 시 복원하지 않고 재인증한다. HttpOnly refresh cookie는 TMB/BFF variant이며 본 AP1 baseline이 아니다.
- 2026-05-25 (owner 위임): issuer identity와 JWKS network address의 실행 wiring은 [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] D6을 따른다. 본 base는 `KC_HOSTNAME` 함정의 배포 축만 소유한다.
- 2026-05-25 (historical, superseded): ~~vanilla JS는 manual fetch + `crypto.subtle` 기반 PKCE 구현 우선~~.
- 2026-07-18: 실제 코딩 순서는 [[raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce]] D1의 **`oidc-client-ts` library-first**다. manual PKCE는 baseline E2E 뒤 비교 학습 단계다.
- 2026-05-25: **realm 1개 + client 1개 (`spa-client`, public, Standard Flow + PKCE S256 강제)**. multi-tenant / role mapping은 out of scope.
## 결정-근거 매핑
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. P3A 는 학습 환경 단순화 결정 다수 → 일부는 `UNSUPPORTED_DECISION` (공식 근거 없이 학습 우선순위 기반).
>
> `선택 조건` 열(R2, 2026-07-18 추가): "이 조건일 때 이 결정, 다른 조건이면 어떤 대안". 분기 없으면 `N/A`.
>
> **Ownership note** — 본 노트는 단일-EC2 배포 base 다. `D1`·`D3` 은 본 branch 고유(HTTPS 경계 · KC_HOSTNAME 배포 축)이고, `D2`·`D4`·`D5` 는 자식 owner 결정의 요약이다(§구현 가이드 §3 위임 맵 · §진행 중 메모 `RESTATED_FOREIGN_DECISION`). 세부는 owner 를 정본으로 본다.
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|---|---|---|---|---|---|
| D1 | 학습 환경에서 HTTPS 없이 진행 (Caddy / Let's Encrypt 는 prod 진입 시 추가) | **학습 환경(localhost / 단일 EC2)에서 OIDC lifecycle 자체가 학습 목표**일 때만 HTTP. 외부 노출·prod 진입 시 HTTPS edge termination 필수(`KC-RP-C4`). 또한 frontchannel 이 HTTPS 여야만 브라우저 `crypto.subtle`(S256 계산)이 secure context 로 동작 — `localhost` 예외에만 HTTP 허용(§구현 가이드 §2 `UNSUPPORTED_IMPL_DECISION`) | `UNSUPPORTED_DECISION` — 공식 문서는 prod 에서 HTTPS edge termination 을 권고 (`raw/official-docs/keycloak-reverseproxy-official.md#KC-RP-C4`) 이고, 학습 환경에서 HTTP 만으로 OIDC 를 진행하라는 권고는 어느 공식 자료에도 없음 | (학습 우선순위 기반 결정) | HTTP 위에서 토큰이 평문 전송 → 학습 환경 외 노출 시 즉시 노출. `KC-CONTAINER-C3` (`start-dev` insecure defaults) 와 결합 시 prod 절대 금지 |
| D2 | pure SPA의 access/refresh token을 모두 memory-only로 보관하고 reload 시 재인증 | **AP1 pure SPA baseline**이면 memory-only. 세션 지속이 요구되면 HttpOnly refresh cookie를 슬쩍 추가하지 않고 AP2(TMB) 또는 AP3(BFF) variant로 전환한다. **owner = [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] D1**, 구현 consumer = [[raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce]] D2 | `raw/official-docs/owasp-html5-storage-xss-spa.md#OWASP-HTML5-C1`, `raw/official-docs/owasp-html5-storage-xss-spa.md#OWASP-HTML5-C2`, `raw/official-docs/owasp-html5-storage-xss-spa.md#OWASP-HTML5-C4` | `official-reference` (owner를 통한 위임) | memory token도 실행 중 XSS에 노출된다. reload UX를 허용할 수 없으면 별도 server-side custody·CSRF 계약을 갖는 variant가 필요 |
| D3 | `KC_HOSTNAME`이 정하는 issuer identity와 backend의 JWKS 도달성을 함께 맞춘다 | 실행 profile의 정확한 값과 network mechanism은 [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] D6이 owner다. 본 base는 단일-host에서 두 조건이 모두 필요하다는 배포 requirement만 소유 | `raw/official-docs/keycloak-hostname-configuration.md#KC-HOST-C2`, `raw/official-docs/keycloak-hostname-configuration.md#KC-HOST-C3`, `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 + delegated` | 실제 Docker profile은 owner D6의 401→200 E2E 전까지 `planned` |
| D4 | 실제 코딩 순서 = `oidc-client-ts` 우선, manual PKCE는 비교 학습용 별도 단계 | baseline E2E를 먼저 확보할 때 library-first. 내부 알고리즘 비교는 이후 manual 단계. **실행 owner = [[raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce]] D1** | owner D1의 `OIDCTS-C2/C3` + `raw/official-docs/oauth2-pkce-rfc-7636.md#PKCE-RFC7636-C2`, `#PKCE-RFC7636-C3` | `delegated official-vendor-doc + official-standard` | manual 단계의 `crypto.subtle` secure-context 동작은 별도 확인 필요 |
| D5 | realm 1개 + client 1개 (`spa-client`, public, Standard Flow + PKCE S256 강제) | **단일 패턴 학습**이면 realm 1 / client 1(spa-client public). hub F3 대로 **4 패턴 통합 base 로 확장 시 realm 은 공유 1개 유지, client 는 패턴당 1개**(spa-public / token-mediating-confidential / bff-confidential / edge-proxy)로 분리 — `aud` 로 client 구분. multi-tenant / role mapping 은 out of scope. **owner = [[raw/branch-notes/feature-keycloak-realm-client-export]] D1** (public+PKCE S256) | `raw/official-docs/oauth2-pkce-rfc-7636.md#PKCE-RFC7636-C1`, `raw/official-docs/oauth2-pkce-rfc-7636.md#PKCE-RFC7636-C3`, `raw/official-docs/keycloak-getting-started-docker.md#KC-GSD-C3`, `raw/official-docs/keycloak-getting-started-docker.md#KC-GSD-C4` | `official-standard + official-vendor-doc` | Keycloak Admin UI 에서 PKCE S256 강제 옵션의 정확한 토글명/위치는 공식 quickstart 인용에 없음 — Admin UI 실 확인 필요 |
## 구현 가이드
> 본 branch 는 단일-EC2 **배포 통합 base** (hub F5) — 실행 코드가 아니라 (1) 물리 토폴로지 경계 명세, (2) `KC_HOSTNAME`/`redirect_uri` 배포측 signature 함정, (3) 6 자식 owner 로의 위임 맵이 산출물이다. 아래 sub-section 은 본 branch 고유 결정(D1·D3·D5)에서만 도출하며, 자식이 owner 인 실행 detail 은 재진술하지 않고 포인터로 위임한다(`rules/consistency-contract.md` Reference-Only). 실 구현 등급은 모두 `planned`(repo 부재 — §진행 중 메모 `NO_GROUND_TRUTH`).
### 1. 단일 EC2 토폴로지 경계 — 누가 어디서 무엇을 하는가
> **Trace**: D3 (KC_HOSTNAME 통일 — `KC-HOST-C2`/`KC-HOST-C3`), D5 (realm/client — `PKCE-RFC7636-C1`/`KC-GSD-C3`), hub F5 (single-EC2 배포 base). 본 §가 §컴포넌트 다이어그램을 결정-trace 로 종결한다.
>
> - **UNSUPPORTED_IMPL_DECISION**: 포트 값(80/8080/8081)·네트워크 메커니즘·컨테이너명은 어느 official 인용도 강제하지 않는다(`KC-CONTAINER-C5` 가 "포트/env 값은 공식 raw verbatim 부재" 로 명시). owner 는 자식 [[raw/branch-notes/feature-keycloak-docker-compose-stack]] D6 — 본 §는 *경계와 노출 정책*만 확정하고 상수는 위임한다.
> - **TOPOLOGY / CORS 귀결 (2026-07-18 depth 감사 반영)**: 본 §가 확정한 **3-포트 직노출**(nginx=static only)은 브라우저에 **2개의 cross-origin 레그**를 만든다 — SPA(:80)→Keycloak(:8080) `/token` + SPA(:80)→backend(:8081) `/api`+`Authorization`. 따라서 Keycloak **Web Origins**(`KC-GSD-C4` — "Set Web origins to ...") + backend **Spring CORS** 가 필수다(§엣지·실패·의존 CORS 경로 + §3 위임 맵). ⚠️ **`TOPOLOGY_DRIFT`**: hub §3-1-5 다이어그램은 nginx `proxy_pass` 리버스프록시(same-origin)를 그리나 본 노트는 3-포트 직노출을 그린다(§진행 중 메모 `TOPOLOGY_DRIFT`). 본 노트가 토폴로지 owner(D3 · hub F5)이므로 학습-최소 3-포트를 기본으로 두되, `proxy_pass` 채택 시 `/api` 는 same-origin 화되어 Spring CORS 가 소거된다 — **그래도 SPA→Keycloak `/token` 은 여전히 cross-origin 이라 Web Origins 는 토폴로지와 무관하게 필수**.
| 컴포넌트 | 역할 (무엇을 보유 / 수행) | 외부 노출 | 근거 | 등급 |
|---|---|---|---|---|
| nginx | vanilla JS SPA static 서빙 (public client) | `:80` (외부) | §컴포넌트 다이어그램; 포트 상수는 child [[raw/branch-notes/feature-keycloak-docker-compose-stack]] D6 | `planned` |
| Spring Boot (Resource Server) | **토큰 미보유** — 요청마다 JWT를 검증하고 audience 계약을 적용 | `:8081` (외부) | [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1/D6 | `planned` |
| Keycloak (Authorization Server) | 토큰 발급 + JWKS publish, `KC_HOSTNAME` 로 issuer hostname 고정 | `:8080` (외부) | `KC-CONTAINER-C1` (KC_HOSTNAME=노출 주소), `KC-HOST-C2`; realm 모델 `KC-GSD-C3` | `planned` |
| PostgreSQL | Keycloak realm/user persistence | 내부 only (미노출) | child [[raw/branch-notes/feature-keycloak-docker-compose-stack]] D2 (dev-file 대신 postgres) | `planned` |
| trust 경계 | 단일 host localhost trust — 외부는 위 3 포트만, backend↔Keycloak JWKS 는 loopback | — | D3 (localhost 통일); §컴포넌트 다이어그램 신뢰 경계 | `planned` |
### 2. 배포측 signature 함정 — `KC_HOSTNAME`(iss) + `redirect_uri`
> **Trace**: D3 (`KC-HOST-C2` hostname 의무·dynamic resolution 차단, `KC-HOST-C3` fraudulent issuer 방어), D5 (`KC-GSD-C4` Valid redirect URIs 설정). 본 §는 **단일 host 배포에서만 발생하는 고유 관심사**이며 §단일 호스트 특이점을 결정-trace 로 종결한다.
>
> - issuer/JWKS의 실행 profile은 child [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] D6이 owner다. 본 §는 함정의 *존재·재현 조건·해결 축*만 확정하고 구체 mechanism을 복제하지 않는다.
> - **UNSUPPORTED_IMPL_DECISION**: 브라우저 `crypto.subtle`(S256 계산)은 **secure context** 에서만 노출되어 non-`localhost` HTTP origin 에선 차단된다. trade-off: 학습 환경의 `localhost` 예외에 의존해 HTTP 를 쓰되(D1), 그 외에는 frontchannel = HTTPS 로 둔다 — 본 corpus 에 이 브라우저 제약의 직접 인용 없음(`oauth2-pkce-rfc-7636.md` Usage Boundaries 도 "추가 확인 필요"로만 기록, §Claims To Verify).
| 함정 | 발생 (재현 조건) | 기대 동작 / 해결 | 근거 | owner (실행) |
|---|---|---|---|---|
| **iss mismatch** | browser 는 토큰의 `iss=http://localhost:8080/realms/...` 를 받고, backend 가 `keycloak:8080`(컨테이너 DNS)을 기대 issuer 로 설정 → 모든 요청 `401` | `KC_HOSTNAME=localhost` 통일 + backend `issuer-uri` 동일 값 + loopback 도달 메커니즘 | `KC-HOST-C2`, `KC-HOST-C3`, `SSRS-JWT-C1` | 재현·해결 → [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] D1/D4/D6 |
| **redirect_uri mismatch** | `http://localhost/callback``http://127.0.0.1/callback``http://<public-ip>/callback` — scheme·host·port·trailing slash 한 글자만 달라도 authorization request 거부 | 등록 URI 와 접근 hostname 1:1 일치 또는 둘 다 등록. wildcard `/*` 는 학습 한정 | `KC-GSD-C4` (Valid redirect URIs 설정 — vendor). exact-match **MUST** 표준(`OA21-C5`)은 본 노트 Sources 밖 → [[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]] D7 이 owner-absent 로 흡수(Reference-Only) | wildcard 정책 → [[raw/branch-notes/feature-keycloak-realm-client-export]] D2; 실 redirect_uri 값 → [[raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce]] D5 (`callback.html`) |
| **crypto.subtle secure context** | non-`localhost` HTTP origin 에서 `crypto.subtle.digest('SHA-256', ...)` 차단 → S256 challenge 계산 불가 → PKCE 흐름 실패 | `localhost` 학습만 HTTP 허용, 그 외 frontchannel = HTTPS | `UNSUPPORTED_IMPL_DECISION` (본 corpus 직접 인용 없음, §Claims To Verify) | [[raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce]] |
### 3. 결정 위임 맵 (6 자식 owner — Reference-Only)
> **Trace**: D2·D4·D5 는 본 base 가 요약만 보유하고 실행 detail 의 owner 는 자식이다(§진행 중 메모 `RESTATED_FOREIGN_DECISION`). `rules/consistency-contract.md` Single-Owner 에 따라 **세부는 owner 를 정본으로** 본다 — 본 표는 포인터 + 1줄 요약만 유지하고 임계값·메커니즘을 재진술하지 않는다.
>
> - **UNSUPPORTED_IMPL_DECISION**: 없음 — 각 행은 owner 노트의 실존 `D<n>` 을 가리킨다(2026-07-18 확인).
| 관심사 | owner (정본) | owner 결정 | 1줄 요약 (본 base 의 인용) |
|---|---|---|---|
| docker-compose 스택 (keycloak+postgres+nginx+spring, healthcheck, realm auto-import) | [[raw/branch-notes/feature-keycloak-docker-compose-stack]] D1~D6 | `start-dev` + postgres + `depends_on: service_healthy` + `--import-realm` + `KC_HOSTNAME=localhost` + 포트/`.env` secret | §컴포넌트 다이어그램·§구현 계획의 docker-compose 항목 정본 |
| realm/client 설정 + JSON export | [[raw/branch-notes/feature-keycloak-realm-client-export]] D1~D5 | public + PKCE S256, redirect wildcard(학습), rotation 값, AT 5분, export+redact commit | 본 base D5 요약의 정본 |
| vanilla JS SPA PKCE 코드 | [[raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce]] D1~D5 | library-first baseline, manual은 비교 학습 단계 | 본 base D4와 정렬 완료 |
| iss 함정 재현·해결 | [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] D1/D4/D6 | issuer identity/JWKS reachability 실행 profile | 본 base D3·§구현 가이드 §2 의 재현·해결 위임 |
| refresh rotation + logout | [[raw/branch-notes/feature-keycloak-refresh-rotation-and-logout]] D1/D2/D4/D5 | rotation ON + Max Reuse 0, AT 5분, revoke/logout 분리, rotation flow 시연 | 본 base D2(refresh 저장) 인접 — rotation 정책 정본 |
| Spring RS + `aud` validator | [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1/D6 | RS 공통 셋업과 단일 backend audience 검증 | 본 base 백엔드 authN 검증 owner |
| role→RBAC (deferred) | [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] D4~D6 | realm/client role을 Spring authority로 변환·강제 | 4패턴 authN E2E 이후 착수 |
| CORS 경계 (Keycloak Web Origins + Spring CORS) | Web Origins → [[raw/branch-notes/feature-keycloak-realm-client-export]] · Spring CORS → SecurityFilterChain (RS 셋업 owner = [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] per `OWNER_SPLIT`) | Web Origins 에 SPA origin 등록(`KC-GSD-C4`) + Spring CORS 로 SPA origin whitelist | 3-포트 직노출의 cross-origin 귀결(§구현 가이드 §1 · §엣지·실패·의존) — 본 base 는 경계·owner 만 지정, 값은 owner |
## 엣지·실패·의존
> R4(깊이 게이트) 캡처용. 본 base 는 `planned`(repo 부재) 이나, 단일-EC2 스택을 실제로 세울 때 부딪힐 실패/엣지와 다른 계약 의존을 미리 열거한다.
- **실패·엣지 경로**:
- **`iss` mismatch (본 배포의 대표 함정)**: browser 는 frontchannel hostname 으로 토큰을 받고 backend 가 컨테이너 DNS(`keycloak:8080`)를 기대 issuer 로 설정하면 전 요청 `401`. 기대 동작: `KC_HOSTNAME=localhost` 통일 + backend `issuer-uri` 동일(D3). 재현·해결과 실행 profile은 [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] D1/D4/D6에 위임한다.
- **`redirect_uri` mismatch → 인증 시작 단계에서 거부**: `iss` 를 맞춰도 그 앞에서 깨지는 경로. `localhost` vs `127.0.0.1` vs public-ip 한 글자만 달라도 authorization request 거부되고 토큰 교환까지 가지 못함. 기대 동작: 등록/접근 hostname 1:1 (§구현 가이드 §2). 근거: `KC-GSD-C4`(vendor) + exact-match MUST 는 [[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]] D7(`OA21-C5`) 참조. owner: realm wildcard 정책 [[raw/branch-notes/feature-keycloak-realm-client-export]] D2 + 실 redirect_uri [[raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce]] D5.
- **`crypto.subtle` 차단 (non-localhost HTTP)**: S256 challenge 계산 불가 → PKCE 흐름 실패. 기대 동작: `localhost` 학습만 HTTP, 그 외 HTTPS(D1). `UNSUPPORTED_IMPL_DECISION` — corpus 직접 인용 없음(§Claims To Verify).
- **CORS preflight 실패 (본 base 3-포트 직노출의 귀결)**: SPA(:80)→Keycloak(:8080) `/token` 과 SPA(:80)→backend(:8081) `/api`+`Authorization` 은 둘 다 cross-origin → whitelist 없으면 브라우저가 preflight 에서 차단. 기대 동작: Keycloak client **Web Origins** 에 SPA origin 등록(`KC-GSD-C4` — "Set Web origins to ...") + backend **Spring CORS** 로 SPA origin 만 허용. `UNSUPPORTED_IMPL_DECISION`(Spring CORS 측): 본 Sources 에 Spring CORS 직접 인용 없음 — 일반 브라우저 동작 원리. 위임: Web Origins → [[raw/branch-notes/feature-keycloak-realm-client-export]], Spring CORS → SecurityFilterChain owner [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] (RS 셋업, `OWNER_SPLIT`). nginx `proxy_pass` same-origin 화 시 `/api` CORS 는 소거되나 `/token` Web Origins 는 잔존(§구현 가이드 §1 `TOPOLOGY_DRIFT`).
- **HTTPS 부재 → 토큰 평문 노출**: `access_token`/`refresh_token` 이 HTTP 헤더/응답으로 평문 전송(D1). 학습 한정, 외부 노출 시 즉시 위험. `KC-CONTAINER-C3`(`start-dev` insecure defaults)와 결합 시 prod 절대 금지.
- **refresh token 재사용 탐지**: rotation ON 상태에서 탈취자와 정상 사용자가 같은 refresh 를 쓰면 token family 전체 무효 → 침해 시그널이자 **정상 사용자 강제 로그아웃**(가용성 비용). 기대 동작: 재로그인 유도. 위임: [[raw/branch-notes/feature-keycloak-refresh-rotation-and-logout]] D1/D5.
- **`aud` 미검증 → cross-client token reuse**: 같은 realm 타 client 토큰이 통과할 수 있다. 기대 동작과 구현 방식은 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1을 따른다.
- **Keycloak 미가용**: backend 는 JWKS 를 캐시하므로 이미 발급된 토큰은 캐시 유효 동안 계속 검증되나 신규 로그인은 즉시 차단. 캐시 TTL 수치는 미검증(§Claims To Verify).
- **SPOF — 단일 EC2 다운 = 전체 정지**(§장점/단점). 운영급은 P2A cluster + Keycloak HA(hub §5 deferred).
- **다른 계약 의존**:
- [[raw/project-notes/keycloak-patterns-overview]] §5 고정 결정 **F5**(실 구현 배포 = single-EC2 1벌) — 본 노트가 그 물리 base. F5 가 바뀌어 cluster/edge E2E 가 요구되면 배포 전략 재설계.
- [[raw/project-notes/keycloak-patterns-overview]] §2 고정 결정 **F1**(taxonomy AP1~AP4) — 본 노트 = AP1 + 배포=single-EC2. branch 재정의 금지(SSOT 는 hub).
- [[raw/project-notes/keycloak-patterns-overview]] §5 고정 결정 **F3**(단일 realm + 패턴당 client 1개) — D5 의 확장 형태. 4 패턴 base 로 쓸 때 client 분리로 `aud` 구분.
- [[raw/project-notes/keycloak-patterns-overview]] §5 고정 결정 **F4**(confidential secret = env var, 미커밋) — AP2/AP3 를 이 base 에 얹을 때 `.env`/`KC_*` 주입, realm export 평문 금지.
- [[raw/branch-notes/feature-keycloak-docker-compose-stack]] D1~D6 — 본 base 스택의 정본. 포트/network/import 가 바뀌면 §컴포넌트 다이어그램 갱신.
- [[raw/branch-notes/feature-keycloak-realm-client-export]] D1~D5 — D5 요약이 의존.
- [[raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce]] D1~D5 — D4와 정렬된 실행 owner.
- [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] D1/D4/D6 — D3 의 재현·해결 위임.
- [[raw/branch-notes/feature-keycloak-refresh-rotation-and-logout]] D1/D5 — D2 인접.
- [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1/D6 — RS 셋업·audience owner. [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] D4~D6 — deferred RBAC owner.
- sibling [[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]] (P2A) D1 — 토큰 교환 흐름 동형(본 §토큰 교환 sequence 가 "P2A와 동일" 선언). AP1 pattern hub 는 P2A.
- sibling [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]] (P3B) — Google 변형. 본 no-google base 에 brokering 을 코드 0줄로 얹음.
## 검증해야 할 주장
> 공식 문서는 근거지만, P3A 학습 환경에서의 실제 동작은 별도 검증 필요.
| Claim | Why uncertain | How to verify | Status |
|---|---|---|---|
| `KC_HOSTNAME=localhost` 가 docker-compose 컨테이너 내부에서 의도대로 작동 (token `iss=http://localhost:8080/realms/...`) | 공식 hostname guide 는 `localhost` 사용 권고가 dev/quickstart 한정. 학습 환경에서 host 네트워크 의존성이 컨테이너 격리와 충돌 가능 | `docker compose up -d` 후 access token 발급 → `jwt.io` 또는 `jq``iss` claim 확인 | `needs-confirmation` |
| `network_mode: host` 가 Linux EC2 에서 정상 동작 (Docker Desktop 가정 제약 회피) | Linux 호스트는 host 네트워크 지원, mac/Windows Docker Desktop 은 제약. 학습 환경이 EC2 Linux 인지 로컬 Docker Desktop 인지에 따라 결과 다름 | EC2 ubuntu 에서 `docker compose ps` + `curl http://localhost:8080/realms/keycloak-patterns/.well-known/openid-configuration` 확인 | `planned` |
| backend (`spring-boot-starter-oauth2-resource-server`) 가 `issuer-uri=http://localhost:8080/...` 로 startup 시 JWKS discovery 성공 | `SSRS-JWT-C2` 는 4단계 discovery 를 보장하지만 컨테이너 → 호스트 loopback 도달성은 별도 | backend 로그에서 `JwtDecoder` 초기화 메시지 + `/api/me` 호출 결과 확인 | `planned` |
| `crypto.subtle.digest('SHA-256', ...)` 가 학습 환경 (`http://localhost`) Secure Context 예외로 사용 가능 | 일반적 HTTP origin 은 Secure Context 아님 → SubtleCrypto 차단. localhost 는 브라우저 vendor 별 예외 처리 | Chrome/Firefox 에서 `app.js` 콘솔에 `await crypto.subtle.digest(...)` 호출 확인 | `needs-confirmation` |
| `redirect_uri=http://localhost/callback` 등록 후 `http://127.0.0.1/callback` 으로 callback 시 Keycloak 이 거부 (의도된 mismatch 시연) | `OA21-C5` exact-match 표준은 있으나 Keycloak 의 실제 enforce 동작 (대소문자, trailing slash, host 동등성) 은 별도 | Admin UI 에서 valid redirect URIs 등록 후 hostname 변형 시 `redirect_uri_mismatch` 에러 확인 | `planned` |
| Keycloak `Revoke Refresh Token: ON` + `Max Reuse: 0` 토글이 refresh rotation 을 실제로 한 번만 허용 | 공식 인용 부재 (oauth2.1 `OA21-C3` 는 scope/resource binding 만 언급) | refresh token 두 번 연속 사용 → 두 번째 호출에서 4xx 응답 확인 | `planned` |
## 마주친 문제
- (구현 시작 후 추가) `KC_HOSTNAME` 설정 misconfiguration으로 인한 issuer mismatch 예상.
- (구현 시작 후 추가) `redirect_uri` 등록 시 `localhost` vs `127.0.0.1` 혼동 예상.
## 구현 계획
- **Repo 위치**: `/home/donghyeon/workspace/keycloak-patterns/` (별도 git repo, LLM Wiki 외부). ⚠️ 2026-07-18 현재 **미존재** — §진행 중 메모 `NO_GROUND_TRUTH`. 아래 전부 `planned`.
- **docker-compose.yml**:
- `keycloak` (`quay.io/keycloak/keycloak:26.x`, `start-dev`, `KC_HOSTNAME=localhost`, `KC_HTTP_ENABLED=true`, `KC_BOOTSTRAP_ADMIN_USERNAME=admin`)
- `postgres` (Keycloak realm persistence, volume mount)
- `backend` (Spring Boot 3 + Java 21, `spring-boot-starter-oauth2-resource-server`)
- `nginx` (static SPA serve, port 80)
- (선택) `caddy` reverse proxy for HTTPS
- (실행 detail 정본: [[raw/branch-notes/feature-keycloak-docker-compose-stack]] D1~D6)
- **SPA**: `index.html` + `app.js` — [[raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce]] D1에 따라 `oidc-client-ts`로 baseline E2E를 먼저 만들고 manual PKCE는 비교 단계에서 수행한다.
- **Backend**:
- Spring Boot 3 + Java 21
- `application.yml`: `spring.security.oauth2.resourceserver.jwt.issuer-uri: http://localhost:8080/realms/keycloak-patterns`
- `/api/me` endpoint with `@AuthenticationPrincipal Jwt` → return `jwt.getClaims()`.
- (실행 detail 정본: [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1/D6; RBAC는 [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] D4~D6)
- **Keycloak realm export JSON**: `keycloak-patterns-realm.json` (realm + client + 테스트 사용자) commit. (실행 detail 정본: [[raw/branch-notes/feature-keycloak-realm-client-export]] D1~D5)
## 관련
- 부모 root: [[raw/branch-notes/feature-keycloak-patterns]]
- 동형 (token flow 동일): [[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]] (P2A Internal SPA + Resource Server, no Google)
- 다음 패턴: [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]] (P3B Single EC2 + Google federation)
## 묶음 (자식 sub-sub-branches — P3A 실 구현 6단계)
<!-- GENERATED: sources:start -->
- [[raw/official-docs/keycloak-getting-started-docker]]
- [[raw/official-docs/keycloak-hostname-configuration]]
- [[raw/official-docs/keycloak-server-containers-docker]]
- [[raw/official-docs/oauth2-pkce-rfc-7636]]
- [[raw/official-docs/oidc-client-ts-library]]
- [[raw/official-docs/spring-security-resource-server-jwt]]
<!-- GENERATED: sources:end -->
- [[raw/branch-notes/feature-keycloak-docker-compose-stack]]
- [[raw/branch-notes/feature-keycloak-realm-client-export]]
- [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]]
- [[raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce]]
- [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]]
- [[raw/branch-notes/feature-keycloak-refresh-rotation-and-logout]]
> Sources는 상단 "외부 근거" 섹션. Errors/Interview/Lectures/Blog 는 현재 없음 — P3A 실 구현(Phase 3) 진입 시 errors / interview-prep 등재 예상.
## 관련 일일 노트
## 완료 후 정리
- PR 링크: (별도 keycloak-patterns repo)
- 리뷰 메모:
- 머지 결과 / 배포 환경: 단일 EC2(또는 로컬 docker-compose) 시뮬레이션, 로컬 검증까지.
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
- `actually-implemented` 항목: (구현 후 채움)
- `locally-verified` 항목: (구현 후 채움)
- `prod-verified` 항목: (없음, prod 배포 out of scope)
- **추출하지 않을 항목** (planned / documented-only / abandoned): 현재 전부 `planned`. 구현 완료된 부분만 wiki/projects/로 승급.