init: llm-wiki-haness 하네스 설계
This commit is contained in:
+276
@@ -0,0 +1,276 @@
|
||||
---
|
||||
title: branch / feature-keycloak-account-linking-spa-ux (Account Linking 정책 — SPA 컨텍스트의 사용자 노출)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-CHILD-1666E2E0
|
||||
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-account-linking-spa-ux
|
||||
parent_branch: feature-keycloak-patterns
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, spa, idp-brokering, google-federation, account-linking, p2b]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 90ab9a8505129356c2a13f017db9741ca882313952664fa7262177c192b79286
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-account-linking-spa-ux (Account Linking 정책 — SPA 컨텍스트)
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/branch-notes/feature-keycloak-patterns]]의 branch-child.
|
||||
> 학습 노트. P2B는 `documented-only` 단계.
|
||||
|
||||
<!-- 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 변형이다 | account-linking SPA UX를 IdP-brokering cross-cutting 학습 자료로 분류한다 | [[raw/project-notes/keycloak-patterns-overview]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
| D1 | automatic email linking 대신 confirm flow를 사용한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
| D2 | SPA UX는 linking trust policy owner의 결론을 소비한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
| D3 | link 상태 표시는 Account REST API 검증 대상으로 둔다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
| D4 | unlink UX는 Account Console을 기본 진입점으로 둔다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
| D5 | link trigger는 Client Initiated Account Linking을 사용한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
없음.
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
P1B의 Account Linking 보안 정책(`sub` primary key + Confirm Link Existing Account)을 **SPA 컨텍스트에서** 어떻게 사용자에게 노출할지 정리. P1B는 oauth2-proxy가 cookie session을 다루지만, P2B는 SPA가 직접 token을 다루므로 **UX 노출 지점이 다름**.
|
||||
|
||||
면접 질문: "기존 Keycloak 사용자가 나중에 Google 로그인을 추가하려면 어떤 흐름인가요?"
|
||||
→ "Keycloak의 Account Console에서 'Linked Accounts' 메뉴를 통해 Google 계정을 link합니다. 또는 로그인 화면에서 Google로 처음 로그인했을 때 같은 email의 기존 계정이 있으면 'Confirm Link Existing Account' 뒤에 owner flow가 선택한 Email verification 또는 password re-authentication이 실행됩니다. SPA는 이 흐름에 직접 관여하지 않고, Keycloak이 redirect로 처리합니다. SPA는 link 완료 후 access token을 받고, 자체 UI로 'Google 계정 연결됨'을 표시할 수 있습니다 (token claim 또는 별도 API 호출)."
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- **3가지 사용자 시나리오**:
|
||||
1. **신규 Google 사용자** (Keycloak에 같은 email 없음) → First Broker Login Flow가 자동 user 생성
|
||||
2. **기존 Keycloak local 사용자**가 Google 로그인 시도 (같은 email) → Confirm Link Existing Account → owner flow의 verification profile(Email 또는 password re-authentication) 통과 후 link
|
||||
3. **이미 link된 사용자** → 평상 로그인
|
||||
- Account 관리 진입점 비교 (SPA 관점):
|
||||
- Keycloak Account Console (`/realms/{r}/account/`) — Keycloak이 제공하는 user-facing UI
|
||||
- SPA 자체 UI + Keycloak Admin REST API
|
||||
- Keycloak Account REST API (사용자 자신의 데이터 조작)
|
||||
- SPA가 "Account linked"를 표시하는 방법
|
||||
- access token claim에 `federated_identities` 포함 불가 (기본). Account REST API 호출 또는 별도 backend endpoint.
|
||||
- unlink 흐름 — Keycloak Account Console의 "Linked Accounts" 메뉴
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 코드 변경 없음 검증 — [[raw/branch-notes/feature-keycloak-federation-spa-zero-change]]
|
||||
- claim mapping — [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]]
|
||||
- JWT signature 검증 — [[raw/branch-notes/feature-keycloak-three-leg-trust-chain]]
|
||||
- P1B와의 흐름 동일성은 [[raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation]] (Edge proxy 컨텍스트) 참고
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
- [[raw/official-docs/keycloak-first-login-flow]] — D1 근거: email 자동 link 는 security hole 공식 경고 + Confirm Link info page 동작
|
||||
- [[raw/official-docs/keycloak-client-initiated-account-linking]] — D5 근거: SPA/client 가 계정 링크를 트리거하는 공식 메커니즘("Client Initiated Account Linking" — 서명된 redirect URL fabrication + `account.manage-account`/`account.manage-account-links` role) + `keycloak.login({action:'link'})` built-in 가정 미확인(does-not-prove)
|
||||
- [[raw/official-docs/google-oidc-discovery-spec]] — D2 위임 컨텍스트: Google `sub` 은 unique/never-reused(`GOOGLE-OIDC-C6`) → email 아닌 `sub` 기반 매칭 근거. [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D4 — linking trust policy를 consume한다.
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] First Broker Login Flow의 default authenticator 흐름 정리 — 등급: `documented-only`
|
||||
- [ ] **Auto-Link 보안 위험** — default `Automatically Set Existing User` 사용 시 hijack 가능. 본 패턴은 사용 금지 — 등급: `documented-only`
|
||||
- [ ] **Confirm Link Existing Account authenticator** 채택 — 후속 verification 방식은 [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D2 — owner profile을 consume — 등급: `documented-only`
|
||||
- [ ] 신규 Google 사용자 first-time 경험: review profile (옵션) → 자동 user 생성 → SPA로 redirect — 등급: `documented-only`
|
||||
- [ ] 기존 사용자 link 시점 — login 화면에서 자동 트리거 vs Account Console에서 명시적 link — 등급: `documented-only`
|
||||
- [ ] SPA가 "현재 link된 IdP 목록" 표시하려면 — Keycloak Account REST API (`GET /realms/{r}/account/linked-accounts`) 호출 필요 — 등급: `needs-confirmation`
|
||||
- [ ] unlink 흐름 — Account Console "Linked Accounts" → Remove. unlink 후 해당 IdP로 로그인 시 다시 first broker login 흐름 — 등급: `documented-only`
|
||||
- [ ] 로컬 비밀번호 없는 사용자가 마지막 federated identity를 unlink하면 Keycloak 엔진이 거부 — [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] D3 — source-grounded, 배포 release tag 재확인 필요 — 등급: `documented-only`
|
||||
- [ ] SPA에서 link/unlink 트리거 시 redirect 흐름 — `keycloak.login({ action: 'link', idpHint: 'google' })` 가능 여부 — 등급: `needs-confirmation` → **조사 반영(2026-07-15)**: 공식 메커니즘은 built-in login action 이 아니라 서명 redirect URL fabrication(D5, [[raw/official-docs/keycloak-client-initiated-account-linking]]). adapter helper 존재 여부만 잔여 확인.
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- **P1B와의 핵심 차이**: P1B는 oauth2-proxy가 cookie session으로 사용자 상태를 가짐. SPA가 없으므로 "Account linked" UI는 별도 페이지(예: `/account/`)로 redirect. P2B는 SPA가 SPA 안에서 "내 계정" 화면을 그리고, link 상태는 API 호출로 가져옴.
|
||||
- **하지만 brokering 흐름 자체는 동일** — Keycloak이 First Broker Login Flow를 실행하고, SPA / proxy는 결과만 받음.
|
||||
- **Auto-Link의 위험** (재확인):
|
||||
- 시나리오: 공격자가 `victim@example.com`로 Google 가입 (Google은 `email_verified=true` 표시) → 그 Google 계정으로 Keycloak 로그인 → 만약 Auto-Link면 `victim@example.com` Keycloak local 계정으로 자동 link → **계정 탈취**.
|
||||
- 방어: `email_verified` 검증만으로는 부족하다. **Confirm Link Existing Account 뒤 소유 증명**이 본질이며, 구체 수단은 [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D2의 조건부 profile(Email 기본 또는 password 재인증 관철/폴백)을 따른다.
|
||||
- **SPA가 link 상태를 표시하는 방법**:
|
||||
1. Keycloak Account REST API `/realms/{r}/account/linked-accounts` 호출 (SPA가 자기 access token 사용 — `account` audience 필요)
|
||||
2. 또는 backend가 Keycloak Admin API를 통해 `GET /admin/realms/{r}/users/{id}/federated-identity` 호출 후 SPA에 노출 (backend는 service account 사용)
|
||||
- **unlink 후 재로그인**: link 해제하면 Federated Identity record가 삭제됨. 다시 Google로 로그인하면 First Broker Login Flow가 다시 실행 → Confirm Link Existing Account가 다시 트리거됨.
|
||||
- **orphan account 방지**: [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] D3 — Keycloak 엔진의 마지막 federated identity 제거 guard를 consume한다. 본 branch는 HTTP 400 UX 처리만 소유하며, 배포 release tag 동일성은 `needs-confirmation`이다.
|
||||
- **조사 반영(2026-07-15, `/branch-spec`)**:
|
||||
- **Claim #4 정정** — keycloak-js 에 `keycloak.login({action:'link'})` 같은 built-in link action 은 KC-CIAL 로 확인 안 됨. 공식 경로는 앱이 `broker/{provider}/link` redirect URL 을 hash 서명과 함께 **직접 fabricate**(D5). keycloak-js/securing-apps 문서 모두 built-in link 메서드를 다루지 않음.
|
||||
- **D2 재framing** — `email_verified` 는 Keycloak 의 *linking gate* 가 아니라 **Trust Email**(외부 IdP 계정 생성 시 verified 표시) 설정(Keycloak admin 문서, WebSearch 비아카이브). [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D4 — linking trust policy를 정본으로 참조하며, 이 branch는 메커니즘을 복제하지 않는다.
|
||||
- **CVE 참고** — first-broker-login 의 cross-session email verification proof 가 upstream identity 에 bound 되지 않는 취약점(CVE-2026-9087, keycloak/keycloak#49175, 비아카이브)이 존재 → 실 구현 단계에서 Keycloak 버전 patch 확인 대상.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
> 추후 면접/회고에서 "왜 이렇게 했나" 답할 근거. 대안과 함께 기록.
|
||||
|
||||
- 2026-05-25: **Auto-Link 금지 / Confirm Link Existing Account 채택** — 이유: 같은 email의 기존 계정 hijack 방지. (P1B와 동일 정책)
|
||||
- 2026-05-25 (**Historical / superseded**): `email_verified=true`만 link 허용한다는 초기 판단. Active policy는 [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D4 — linking trust policy를 참조한다.
|
||||
- 2026-05-25: **SPA의 link 상태 표시 = Keycloak Account REST API 호출** — backend 거치지 않고 SPA가 직접. 이유: backend 코드 추가 0 (P2A와 동일하게 유지). Account API audience(`account`)가 SPA token에 자동 포함되는지 확인 필요 (`needs-confirmation`).
|
||||
- 2026-05-25: **unlink 흐름 = Keycloak Account Console 사용** — SPA 자체 UI는 학습 단계에선 미구현. 운영 시 SPA 안에 카드형 UI 추가 검토.
|
||||
- 2026-07-15 (`/branch-spec` 조사): **D5 추가 — SPA 가 link 를 트리거하는 공식 메커니즘 = Client Initiated Account Linking (서명된 redirect URL fabrication)** — keycloak-js built-in login action 이 아님. 검토한 대안: (a) `keycloak.login({action:'link'})` built-in — 공식 근거 없음, (b) 앱이 `broker/{provider}/link?...&hash=` URL 직접 구성 — 공식(KC-CIAL). 근거: [[raw/official-docs/keycloak-client-initiated-account-linking]].
|
||||
- 2026-07-15 (`/branch-spec` 재framing): **D2 는 이 브랜치가 소유하지 않고 위임(consume)** — [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D4 — linking trust policy. 이 브랜치는 SPA 컨텍스트에서 동일 게이트를 재진술하지 않고 참조만 한다(consistency-contract Single-Owner).
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시한다.
|
||||
> `Decision ID` 는 이 branch-note 안에서 안정적으로 유지한다.
|
||||
> `Supporting Claims` 는 `raw/<category>/<slug>.md#<CLAIM-ID>` 형식.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | Auto-Link 금지 / Confirm Link Existing Account 채택 (같은 email 의 기존 계정 hijack 방지) | 외부 IdP email 을 항상 신뢰 못할 때(=일반 케이스) → Confirm Link. 폐쇄망에서 IdP 를 완전 신뢰하면 Trust Email + auto-link 도 가능하나 본 패턴 미채택 | `raw/official-docs/keycloak-first-login-flow.md#KC-FLF-C2`, `raw/official-docs/keycloak-first-login-flow.md#KC-FLF-C3` — flow *구성*(정확한 authenticator step)은 owner [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D1(auto-link `DISABLED`)·D2(Confirm Link + Verify Existing Account — SMTP 시 Email 기본 / Re-auth 는 email authenticator DISABLE 시)에 위임 | `official-vendor-doc` (공식 경고 + Confirm Link info page 동작) | "Confirm Link Existing Account" 가 default flow 에 포함되는지 vs 별도 추가 필요한지 Keycloak version 별 확인 필요 (owner 브랜치 D2 에서 추적) |
|
||||
| D2 | SPA UX는 linking trust policy를 새로 정하지 않고 owner 결론을 consume | 정책을 바꾸려면 owner에서 변경. 이 branch는 사용자 노출과 상태 표시만 다룸(Reference-Only) | [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D4 — linking trust policy | `delegated` | `Trust Email`은 linking gate가 아니다. custom SPI artifact가 확인되지 않은 상태에서 이 branch가 별도 hard-reject를 주장하지 않음 |
|
||||
| D3 | SPA 의 link *상태 표시* = Keycloak Account REST API (`GET /realms/{r}/account/linked-accounts`) 직접 호출 | backend 없이 SPA 가 상태 표시해야 → Account REST API 시도(미문서화, `needs-confirmation`). backend 가 이미 있으면 → Admin API `federated-identity`(문서화, service account)가 안전 | UNSUPPORTED_DECISION | — | KC-CIAL(신규 Source)이 이 `linked-accounts` read endpoint 를 **다루지 않음을 명시 확인**(does-not-prove) + 커뮤니티상 undocumented(WebSearch). `account` audience 자동 포함 여부는 Claims To Verify 로 이관 |
|
||||
| D4 | unlink UX 진입점 = Keycloak Account Console (SPA 자체 UI 미구현) — UX 진입점 선택만 소유 | 학습 단계 → 기본 Account Console UI(코드 0). 운영에서 in-SPA unlink UX 필요 → SPA 자체 카드 UI(별도 구현) | UNSUPPORTED_DECISION (UX 진입점 선택); [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] D3 — unlink 안전정책 owner | — | 엔진 guard는 source-grounded이나 인용 기준이 Keycloak `main`이므로 배포 release tag 재확인 필요. 본 branch는 400 UX만 결정 |
|
||||
| D5 | SPA 가 link 를 **트리거**하는 방법 = Client Initiated Account Linking (앱이 서명된 redirect URL 을 fabricate), keycloak-js built-in login action 아님 | 로그인된 사용자에게 "지금 Google 연결" 버튼 제공 → client-initiated linking URL fabricate(D5). 최초 로그인 시 자동 link 은 First Broker Login(D1) 경로 — 트리거 시점이 다름 | `raw/official-docs/keycloak-client-initiated-account-linking.md#KC-CIAL-C1`, `raw/official-docs/keycloak-client-initiated-account-linking.md#KC-CIAL-C2`, `raw/official-docs/keycloak-client-initiated-account-linking.md#KC-CIAL-C3`, `raw/official-docs/keycloak-client-initiated-account-linking.md#KC-CIAL-C4` | `official-vendor-doc` | SPA(브라우저)가 hash 재료(`token.getSessionState()`/`token.getIssuedFor()`)를 keycloak-js `tokenParsed` 에서 얻는지 코드 미확인(`planned`). `KC-CIAL-C4` does-not-prove: 문서가 "CSRF 를 완전히 막지는 못한다" 경고 → SPA state 별도 방어 필요 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> *결정(Decisions)* 이 "*무엇*" 이라면 본 §는 "*어디에 어떻게*" 의 사전 명세. 본 브랜치는 `documented-only` 학습 노트이므로, 구현 detail 은 **공식 문서가 규정하는 프로토콜·값**을 anchor 로 하고 코드 미확인 항목은 `planned`/`UNSUPPORTED_IMPL_DECISION` 로 명시한다.
|
||||
|
||||
### 1. 계정 링크 트리거 — Client Initiated Account Linking (SPA → Keycloak)
|
||||
|
||||
> **Trace**: D5 + `KC-CIAL-C1`/`C2`/`C3`/`C4`. 이미 로그인된 SPA 사용자가 "Google 연결" 버튼을 눌렀을 때의 사전 명세.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (a) SPA(브라우저)가 hash 재료 `token.getSessionState()`·`token.getIssuedFor()` 를 keycloak-js `tokenParsed.session_state`·`tokenParsed.azp` 로 얻는 매핑 — KC-CIAL 예시는 Java Servlet 전용(`KC-CIAL-C3` does-not-prove), JS 매핑은 미확인. trade-off: 학습 단계엔 이 매핑을 `planned` 로 남기고 실 구현 시 keycloak-js `tokenParsed` 필드로 검증. (b) 브라우저에서 SHA-256 계산은 `crypto.subtle.digest('SHA-256', ...)` + Base64URL 인코딩으로 수행 — 표준 Web Crypto 이나 KC-CIAL 이 JS 구현을 규정하지 않으므로 `UNSUPPORTED_IMPL_DECISION`.
|
||||
|
||||
| 항목 | 값 / 명세 | 근거 |
|
||||
|---|---|---|
|
||||
| redirect URL 템플릿 | `{auth-server-root}/auth/realms/{realm}/broker/{provider}/link?client_id={id}&redirect_uri={uri}&nonce={nonce}&hash={hash}` | `KC-CIAL-C3` |
|
||||
| `provider` | `google` (Keycloak IdP alias) | D5, `KC-CIAL-C3` (provider 파라미터) |
|
||||
| `hash` 계산 | Base64URL( SHA_256( `nonce` + `token.getSessionState()` + `token.getIssuedFor()` + `provider` ) ) | `KC-CIAL-C4` |
|
||||
| 전제조건 | 사용자에 `account.manage-account` 또는 `account.manage-account-links` role + 그 role 의 scope 가 access token 에 부여 + 앱이 자기 access token 접근 | `KC-CIAL-C2` |
|
||||
| 목적(왜 hash) | auth server 가 client 가 요청을 시작했음을 보장(rogue app 임의 link 방지) — 단 CSRF 완전 방지는 아님 | `KC-CIAL-C4` |
|
||||
|
||||
### 2. 최초 로그인 링크 경로 — First Broker Login (Google 로그인 화면 → Keycloak)
|
||||
|
||||
> **Trace**: D1 + `KC-FLF-C2`/`C3`/`C4`. §1 과 다른 트리거 시점(로그인 시 email collision).
|
||||
>
|
||||
> - **OUT_OF_BRANCH_SCOPE**: [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D2 — Confirm Link 이후 verification profile의 정본. 이 브랜치는 owner 결과에 따른 UX만 기술한다.
|
||||
|
||||
| 시나리오 | 동작 | 근거 |
|
||||
|---|---|---|
|
||||
| 신규 Google 사용자(같은 email 없음) | 자동 user 생성. Review Profile mode 에 따라 확인 페이지(`On`/`missing`/`Off`) | `KC-FLF-C4` |
|
||||
| 같은 email 기존 사용자 | Confirm Link Existing Account info page → (A) profile 재검토 후 다른 email/username, (B) 기존 계정 link 확인 | `KC-FLF-C3` |
|
||||
| 금지 | `Automatically Set Existing User`(auto-link) — email 자동 link 는 security hole | `KC-FLF-C2` |
|
||||
|
||||
### 3. 링크 상태 표시 (Read path)
|
||||
|
||||
> **Trace**: D3 (UNSUPPORTED_DECISION). SPA 가 "현재 연결된 IdP" 를 그리는 방법.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 옵션 A 의 `/account/linked-accounts` endpoint 는 KC-CIAL 가 다루지 않고 커뮤니티상 undocumented. trade-off: **backend-zero(옵션 A, 미검증)** vs **문서화된 안정성(옵션 B, backend 코드 추가)**. 학습 단계엔 옵션 A 를 `planned` 로 시도하되 실패 시 옵션 B fallback.
|
||||
|
||||
| 옵션 | 호출 | 인증 | 상태 |
|
||||
|---|---|---|---|
|
||||
| A | SPA → `GET /realms/{r}/account/linked-accounts` | SPA access token (`account` audience 필요) | `needs-confirmation` (undocumented) |
|
||||
| B | backend → `GET /admin/realms/{r}/users/{id}/federated-identity` | service account | `documented-only` (Admin API) |
|
||||
|
||||
### 4. Unlink
|
||||
|
||||
> **Trace**: D4 (UNSUPPORTED_DECISION, UX 진입점). [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] D3 — 안전정책 owner.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: Account Console UI를 재사용할지 SPA 자체 unlink 카드를 만들지는 project UX 선택이다. orphan 거부 자체는 owner D3의 엔진 guard를 consume하며, 이 branch는 HTTP 400 안내를 처리한다.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4 캡처용. 정상 경로 외 실패/엣지/의존.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **hash 누락/불일치 (§1)**: `broker/{provider}/link` 에 `hash` 가 없거나 틀리면 auth server 가 link 요청을 client-initiated 로 인정하지 않음(`KC-CIAL-C4`). 단 문서가 "CSRF 를 완전히 막지 못함" 경고 → SPA 는 별도 `state` 로 CSRF 방어 필요.
|
||||
- **Auto-Link hijack (§2)**: 공격자가 `victim@example.com` 로 Google 가입 후 그 IdP 로 로그인 → auto-link 면 계정 탈취. 방어 = D1 Confirm Link + [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D2의 owner-selected verification profile(Email 기본 또는 password 재인증 관철/폴백); `email_verified` 만으로는 불충분(`KC-FLF-C2`).
|
||||
- **orphan account (§4)**: [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] D3 — 마지막 federated identity 제거 guard를 consume. 배포 release tag가 owner 근거와 같은지는 `needs-confirmation`.
|
||||
- **unlink 후 재로그인 (§4)**: Federated Identity record 삭제 → 다시 Google 로그인 시 First Broker Login 재실행 → Confirm Link 재트리거(D1 경로).
|
||||
- **`account` audience 부재 (§3)**: SPA access token 의 `aud` 에 `account` 미포함 시 옵션 A 는 401/403 → 옵션 B fallback 필요.
|
||||
- **CVE-2026-9087 (§2)**: first-broker-login 의 cross-session email verification proof 가 upstream identity 에 bound 안 됨(keycloak/keycloak#49175, 비아카이브). 실 구현 시 Keycloak 버전 patch 상태 확인.
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D1 — silent auto-link 방지 owner. [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D2 — verification profile owner.
|
||||
- [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] D1 — persistent federation key owner. [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] D3 — unlink guard owner.
|
||||
- [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D4 — linking trust policy; 본 branch는 UX만 consume.
|
||||
- [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D4 — Google claim→user attribute mapping owner.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서 근거가 있어도 내 프로젝트에서의 동작을 자동으로 보장하지 않는다.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| "Confirm Link Existing Account" authenticator 가 P2B 의 default first-broker-login flow 에 자동 포함된다 | KC-FLF-C3 는 authenticator 의 동작만 정의하며, default flow inclusion 여부는 Keycloak version (26.x) 별 admin UI 에서 확인 필요 | Keycloak admin console > Authentication > Flows > First Broker Login 의 step list 캡처 | `needs-confirmation` |
|
||||
| SPA access token 의 `aud` claim 에 `account` audience 가 자동 포함되어 Account REST API 호출 가능 | 본 branch 의 Sources 에 audience mapping 동작 문서 없음. Keycloak default client 설정 의존 | dev 환경에서 SPA access token decode 후 `aud` 필드 확인 + `GET /realms/{r}/account/linked-accounts` 호출 결과 200 확인 | `needs-confirmation` |
|
||||
| Keycloak Account REST API 에 `linked-accounts` read endpoint 가 실제로 존재하고 SPA 로 호출 가능하다 | KC-CIAL 은 이 endpoint 를 다루지 않음(does-not-prove); 커뮤니티상 undocumented(WebSearch) | dev 환경에서 실 호출 + 응답 schema 확인, 또는 옵션 B(Admin API `federated-identity`)로 대체 | `needs-confirmation` |
|
||||
| 인용한 Keycloak 엔진 unlink guard가 배포 release tag에서도 동일하게 동작한다 | [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] D3의 근거는 Keycloak `main` source이며 배포 image tag와 동일성은 아직 확인하지 않음 | 배포 tag의 `LinkedAccountsResource` guard 대조 + Google-only 사용자로 마지막 link 제거 시 HTTP 400 확인 | `needs-confirmation` (owner decision은 source-grounded) |
|
||||
| SPA 에서 link 트리거는 built-in `keycloak.login({action:'link'})` 가 아니라 `broker/{provider}/link` 서명 URL fabrication 이다 | KC-CIAL(`KC-CIAL-C1`/`C3`)은 앱이 URL 을 직접 fabricate 함을 규정하나 adapter API 표면은 다루지 않음 — keycloak-js 가 helper 를 제공하는지는 미확인 | keycloak-js 공식 adapter 레퍼런스에서 `login()` action 지원 목록 확인 + dev 환경에서 서명 URL 수동 구성 테스트 | `needs-confirmation` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- (학습 단계, 미실행)
|
||||
- **잠재적 함정**: SPA가 Account REST API를 호출할 때 access token의 `aud`에 `account`가 포함되어야 함. Keycloak 기본 client 설정에서 `account` audience가 자동 포함되는지 확인 필요.
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/keycloak-client-initiated-account-linking]]
|
||||
- [[raw/official-docs/keycloak-first-broker-login-flow]]
|
||||
- [[raw/official-docs/keycloak-first-login-flow]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
> 본 sub-sub-branch 는 leaf — 자식 자료 없음. Phase 3 P3A 실 구현 또는 외부 산출물 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 오류 기록 (이 sub-sub-branch 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 documented-only 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — Phase 3 실 구현 단계에 누적)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 학습 노트. P2B는 `documented-only` 유지.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: 학습 노트 (`documented-only`)
|
||||
- **wiki 추출 대상**:
|
||||
- `actually-implemented` 항목: (없음)
|
||||
- `locally-verified` 항목: (없음)
|
||||
- `prod-verified` 항목: (없음)
|
||||
- **추출하지 않을 항목**: 전 항목 (`documented-only` / `needs-confirmation`)
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
---
|
||||
title: branch / feature-keycloak-account-linking-sub-vs-email (Account Linking 보안 — sub vs email)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-PATTERNS-OVERVIEW-018
|
||||
kind: project-work-item
|
||||
project: keycloak-patterns-overview
|
||||
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-018
|
||||
inherits: [DEC-KEYCLOAK-PATTERNS-OVERVIEW-IDP-BROKERING-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-KEYCLOAK-PATTERNS-OVERVIEW-016]
|
||||
contract_packet: 1
|
||||
branch: feature-keycloak-account-linking-sub-vs-email
|
||||
parent_branch:
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, p1b, account-linking, security, account-takeover]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 54e36081984bb3b7bb0b46f7bd31beb8a7f6ff170e7c6ca76c76f4d0228bf27d
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-account-linking-sub-vs-email (Account Linking 보안 — sub vs email)
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/project-notes/keycloak-patterns-overview]] Work Item의 직접 자식 branch.
|
||||
> 학습 노트: P1B는 `documented-only` (실 구현 안 함).
|
||||
> `status_label`: `in-progress`
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/project-notes/keycloak-patterns-overview]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: sub와 email linking key의 security comparison과 선택이 기록된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-IDP-BROKERING-001@1` | Google federation은 Keycloak IdP brokering과 hardened First Broker Login을 사용한다 | federated identity의 persistent key와 account-linking security policy에 적용한다 | [[raw/project-notes/keycloak-patterns-overview]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
| D1 | federated identity의 persistent key로 Google sub를 사용한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
| D2 | 기존 local 계정 linking 시 재인증 조건을 고정한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
| D3 | self-service unlink lockout을 server guard와 UX로 처리한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
| D4 | self-service link의 추가 password 재확인은 근거 확보 전 보류한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
| D5 | attribute Sync Mode 선택은 별도 owner에 위임한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
없음.
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
Google federation 운영의 가장 큰 보안 함정은 **Account Linking의 primary key 선택**이다. email 기반 linking은 직관적이지만, 다음 두 사실 때문에 account takeover 시나리오를 만든다.
|
||||
|
||||
1. **email은 변경 가능**: Google 계정 소유자가 primary email을 변경할 수 있음.
|
||||
2. **email은 재사용 가능**: Google Workspace에서 퇴사자 email이 신규 직원에게 재배정될 수 있음 ([[raw/company-tech-blogs/keycloak-google-login-codemancers]] 명시).
|
||||
3. **`email_verified=false`인 Google 사용자 존재**: 일부 케이스에서 Google이 미인증 email로 ID token 발급 가능.
|
||||
|
||||
반면 **`sub` claim은 영구·불변**이며 Google이 사용자별로 발급한 globally unique ID. Account linking의 primary key는 반드시 `sub`여야 한다.
|
||||
|
||||
본 노트는 takeover 시나리오를 정리하고, sub 기반 linking 정책을 명시한다.
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- Account linking primary key 선택 (`sub` vs email) 및 그 근거 — takeover 위협 모델 A/B/C (본 branch 의 **core owned 결정 D1**).
|
||||
- self-service unlink 의 lockout-safe 정책 — password 미설정 계정 보호 (본 branch owned **D3**; [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]] D4 가 안전정책을 본 branch 에 위임).
|
||||
- 기존 local 계정에 IdP link 시 재인증 *정책 수준* 요구 (D2 — flow *구성* 은 sibling 위임).
|
||||
- Sync Mode 가 takeover 안전성에 미치는 영향 *분석* (D5 — 직교성 확인; 선택 자체는 sibling 위임).
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- 실제 Keycloak 구성·코드 구현 (본 sub-sub-branch 는 `documented-only` 학습 노트).
|
||||
- First Broker Login Flow 의 authenticator step 값 구성 → [[raw/branch-notes/feature-keycloak-first-broker-login-flow]].
|
||||
- Google claim → attribute mapper 구성 및 Sync Mode 값 선택 → [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]].
|
||||
- SPA link/unlink UX 진입점 및 client-initiated linking 프로토콜 → [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]].
|
||||
- **sub-only 충돌 감지 authenticator** — OOTB `Create User If Unique` 는 email/username 매칭(`KC-FBLVERIFY-C5`)이므로 sub-only 매칭엔 커스텀 authenticator 필요. 별도 branch 대상 (§Audit & Findings 이관 권고).
|
||||
- 비-Google IdP / SAML federation.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
- [[raw/company-tech-blogs/keycloak-google-login-codemancers]] — 실무 사례: email 재배정 takeover 시나리오 + sub 기반 linking 권장 (`company-case-study` — corroboration 전용).
|
||||
- [[raw/official-docs/keycloak-first-login-flow]] — Confirm Link Existing Account flow 공식 (auto-link = security hole).
|
||||
- [[raw/official-docs/keycloak-first-broker-login-verify-authenticators-official]] — D2 정정 근거: "Verify Existing Account By Email" (SMTP 설정 시 `ALTERNATIVE` 기본값) vs "Verify Existing Account By Re-authentication" (email authenticator 사용 불가 시 fallback) 의 정확한 트리거 조건. 재인증은 기본값이 아니며, 강제하려면 관리자가 email authenticator 를 명시적으로 disable 해야 함.
|
||||
- [[raw/official-docs/google-openid-connect-oidc]] — Google `sub` claim의 영구성 + `email_verified` 의미 ("Always use the sub field").
|
||||
- [[raw/official-docs/keycloak-identity-brokering-overview-official]] — federated identity 모델.
|
||||
- [[raw/official-docs/keycloak-account-console-unlink-lockout-guard-official]] — 엔진 소스 코드: Account Console self-service unlink 가 마지막 federated identity 제거를 password 미설정 시 HTTP 400 으로 거부하는 lockout guard (D3 근거).
|
||||
- [[raw/official-docs/keycloak-identity-provider-sync-mode-official]] — D5 (Sync Mode = IMPORT) 공식 근거 (IMPORT/FORCE/LEGACY/INHERIT verbatim). Sync Mode 는 attribute 최신성만 다루며 linking key(`sub`) 안전성과는 무관함을 명시.
|
||||
- [[raw/official-docs/keycloak-client-initiated-account-linking]] — D4 반대 근거: self-service link 전제 = role(`account.manage-account-links`) + access token 만 (`KC-CIAL-C2`), password 재확인 미언급.
|
||||
|
||||
## TODO (과거 계획 스냅샷)
|
||||
|
||||
각 항목 옆에 증거 등급. 아래는 2026-05-25 계획 스냅샷이며 active 정책은 §Decision Evidence Map을 따른다. 본 P1B sub-sub-branch는 전체 `documented-only` / `planned`.
|
||||
|
||||
- [ ] Takeover 시나리오 (A/B/C) 다이어그램화 — 등급: `planned`
|
||||
- [ ] Keycloak federated identity 테이블 스키마 확인 — 등급: `needs-confirmation`
|
||||
- 테이블명: `FEDERATED_IDENTITY`
|
||||
- composite key: `(IDENTITY_PROVIDER, FEDERATED_USER_ID, USER_ID)` 추정 — 확인 필요
|
||||
- [ ] sub 기반 linking 강제 정책 명시 — 등급: `documented-only`
|
||||
- email 기반 자동 linking 금지 (앞선 -2-2 노트와 연결)
|
||||
- federated identity primary key = Google `sub` claim
|
||||
- [ ] 기존 Keycloak 계정에 federated identity link 시 password 재확인 정책 — 등급: `documented-only`
|
||||
- "Confirm Link Existing Account" + "Verify Existing Account by Re-authentication" REQUIRED
|
||||
- 사용자가 기존 계정 password를 입력해야 link 완료
|
||||
- [ ] Account Console에서 사용자 link/unlink 정책 결정 — 등급: `documented-only`
|
||||
- Self-service unlink 허용 시: 사용자가 비밀번호 미설정 상태에서 unlink → 잠금 위험 (대안 로그인 수단 미보유) → 사전 password 설정 강제
|
||||
- Self-service link 허용 시: 사용자가 Account Console에서 새 Google 계정 link → 같은 takeover 위험 → password 재확인 필수
|
||||
- [ ] email 변경 시 user attribute 동기화 정책 — 등급: `documented-only`
|
||||
- Sync Mode FORCE면 매 로그인마다 갱신
|
||||
- Sync Mode IMPORT면 first login 시점만 → 이후 Google 측 변경 무시 (안정성 ↑, 최신성 ↓)
|
||||
|
||||
> ⚠️ **2026-07-15 조사 정정 (위 항목 전제 수정)**: password 재인증(D2)·unlink lockout(D3)·self-service link 재확인(D4)·Sync Mode(D5) 관련 전제 일부는 공식 문서·엔진 소스 조사로 수정됐다. 원 TODO 는 verbatim 보존하되, 정정 내용은 §Decision Evidence Map 의 Open Risk 열 + §Audit & Findings 를 따른다.
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- [[raw/company-tech-blogs/keycloak-google-login-codemancers]]가 명시: **"전 직원 email이 신규 직원에게 재배정되는 케이스 → email 기반 linking은 위험"**. 본 노트의 핵심 출처.
|
||||
- Keycloak 공식 문서가 "Confirm Link Existing Account" flow를 보안 기본값으로 권장하는 이유가 바로 본 노트의 시나리오들.
|
||||
- 면접에서 받기 좋은 질문: "왜 sub를 primary key로 쓰나? email로 하면 안 되나?" — Scenario A/B로 답변 가능.
|
||||
- 본 정책은 P1B 한정이 아닌 모든 Google federation 패턴(P2B, P3B)에 동일 적용.
|
||||
|
||||
## 계정 탈취 시나리오 분석
|
||||
|
||||
> 본 § 는 §결정 사항의 근거가 되는 위협 모델. 각 결정(특히 D1)이 어떤 공격을 막는지의 분석.
|
||||
|
||||
### Scenario A: email 재배정 (퇴사자 → 신규 직원)
|
||||
|
||||
1. `alice@company.com` (Google sub = `sub_A`)이 Keycloak 계정 보유, federated identity = `sub_A`.
|
||||
2. Alice 퇴사 → IT 관리자가 Google Workspace에서 alice@company.com 계정 삭제.
|
||||
3. 신규 직원 Bob에게 같은 `alice@company.com` email 재배정 (Google sub = `sub_B`).
|
||||
4. Bob이 Google 로그인 시도 → Keycloak이 받은 ID token의 `sub` = `sub_B`.
|
||||
5. **email 기반 linking이면**: Keycloak이 email match로 Alice의 기존 계정에 Bob을 link → **Bob이 Alice의 권한 + 데이터에 접근**.
|
||||
6. **sub 기반 linking이면**: `sub_B`로 federated identity 검색 → 미존재 → 신규 user 생성 (또는 confirm flow). 안전.
|
||||
|
||||
### Scenario B: 자체 email 변경 (Google 계정 소유자)
|
||||
|
||||
1. `eve@gmail.com` (sub = `sub_E`)이 Keycloak에 신규 가입 (federated identity = `sub_E`).
|
||||
2. Eve가 자신의 Google 계정 primary email을 `victim@gmail.com`으로 변경 (Google이 허용하는 시나리오 — alias 변경 등).
|
||||
3. Keycloak에 이미 `victim@gmail.com`으로 가입된 별개 사용자 Victim 존재.
|
||||
4. **email 기반 매 로그인 재확인이면**: Eve가 다음 로그인 시 Keycloak이 새 email로 Victim 계정에 link 시도 → takeover.
|
||||
5. **sub 기반이면**: `sub_E`로 매핑된 Eve 계정 그대로 사용. email attribute만 갱신 (sync mode FORCE) 또는 그대로 (IMPORT). → **Sync Mode 는 takeover 방지에 관여하지 않음**; 방지 주체는 sub 기반 linking (§Audit RATIONALE_CORRECTION).
|
||||
|
||||
### Scenario C: email_verified=false
|
||||
|
||||
1. 공격자가 Google OAuth client를 자체 운영하면서 `email_verified=false`인 임의 email을 가진 사용자로 가장.
|
||||
2. Keycloak `trustEmail=true`로 설정돼 있으면 email match로 기존 victim 계정에 link.
|
||||
3. 해결: `trustEmail=false` + First Broker Login Flow에 `Confirm Link Existing Account` (이미 [[raw/branch-notes/feature-keycloak-first-broker-login-flow]]에서 다룸).
|
||||
|
||||
## 결정 사항 (과거 기록)
|
||||
|
||||
> 2026-05-25 초기 판단의 보존 영역이다. **Active 정본은 아래 Decision Evidence Map이며**, D2와 D5의 값·선택 조건은 각 owner를 참조한다.
|
||||
|
||||
- 2026-05-25: federated identity primary key = **Google `sub` claim 만**. email은 attribute일 뿐 link key 아님.
|
||||
- 2026-05-25: 기존 Keycloak local 계정에 Google federated identity 추가 link 시 → 기존 계정 password 재인증 필수 ("Verify Existing Account by Re-authentication" REQUIRED).
|
||||
- 2026-05-25: Account Console self-service unlink는 사용자가 password를 설정한 경우에만 허용 (잠금 방지).
|
||||
- 2026-05-25: Self-service link 시점에도 기존 password 재확인 강제.
|
||||
- 2026-05-25: ~~Sync Mode = IMPORT (first login만). Google 측 email 변경이 Keycloak으로 자동 전파되지 않음 → Scenario B 회피.~~ **Superseded by D5** — takeover와 Sync Mode는 직교하며 값 선택은 owner가 소유한다.
|
||||
|
||||
> **정정 메모 (2026-07-15 조사)**: 위 원 결정 중 D2/D3/D4/D5 는 공식 문서·엔진 소스 조사로 일부 전제가 수정됐다 — 원문은 위에 verbatim 보존하고, 수정 내용은 §Decision Evidence Map 의 Open Risk 열과 §Audit & Findings 에 기록한다 (CLAUDE.md §11: 사용자 작성 결정은 자동 rewrite 금지, 정합 권고만).
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시한다.
|
||||
> `Decision ID` 는 이 branch-note 안에서 안정적으로 유지한다.
|
||||
> `Supporting Claims` 는 `raw/<category>/<slug>.md#<CLAIM-ID>` 형식.
|
||||
> `선택 조건` (R2): 이 조건일 때 이 결정 / 다른 조건이면 어떤 대안.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | federated identity primary key = Google `sub` claim 만 (email 은 link key 아님) — **본 branch core owned** | 항상 이 결정 — `sub` 는 불변·유일, email 은 변경(GOIDC-C3)·재배정(Scenario A) 가능하므로 link key 부적격. 대안(email 기반 link)은 email 의 불변·비재사용이 IdP 계약으로 보장될 때만 — Google 은 명시적 불가 | `raw/official-docs/google-openid-connect-oidc.md#GOIDC-C3` ("Always use the sub field ... even if the user changes their email address"), `raw/company-tech-blogs/keycloak-google-login-codemancers.md` (tech-blog 사례 — corroboration 전용, 공식 best practice 단정 금지) | `official-vendor-doc` (Google) + `company-case-study` | Keycloak `FEDERATED_IDENTITY` 스키마가 sub 를 어떻게 저장하는지 본 Sources 직접 보장 안 함(→ Claims To Verify). **OOTB `Create User If Unique` 는 email/username 으로 충돌 감지**(`raw/official-docs/keycloak-first-broker-login-verify-authenticators-official.md#KC-FBLVERIFY-C5`) → sub-only 매칭엔 커스텀 authenticator 필요(§Audit OUT_OF_BRANCH_SCOPE) |
|
||||
| D2 | 기존 Keycloak local 계정에 Google federated identity link 시 → 기존 계정 password 재인증 — **단 기본 동작 아님**: SMTP 설정 realm 은 "Verify Existing Account By Email" 이 기본 `ALTERNATIVE` 이므로 admin 이 명시 DISABLE 해야 password 재인증 실행 | security-first(secret 소유 증명) → email authenticator DISABLE + Re-authentication. SMTP 미설정 → Re-authentication 자동 폴백. 소비자 서비스(마찰·지원부담 우선) → Email 검증 기본값 유지 가능(단 secret 미증명) | `raw/official-docs/keycloak-first-login-flow.md#KC-FLF-C2` (auto-link=security hole), `raw/official-docs/keycloak-first-broker-login-verify-authenticators-official.md#KC-FBLVERIFY-C1` (Email = SMTP 시 기본), `#KC-FBLVERIFY-C2` (재인증 관철 = email DISABLE), `#KC-FBLVERIFY-C3` (Re-auth 폴백). flow *구성* owner = [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D2 | `official-vendor-doc` | Google-first 가입(비밀번호 미설정) 사용자는 Re-authentication 으로 재인증 수단 없어 lockout 가능 — 사용자 population 조사 필요(`needs-confirmation`) |
|
||||
| D3 | Account Console self-service unlink lockout 방지는 **Keycloak 엔진이 서버에서 이미 강제** (마지막 federated identity 제거는 `count>1 \|\| user.isFederated() \|\| isPasswordSet()` 아니면 HTTP 400). "사전 password 설정 강제" 는 보안 필수 아니라 UX 개선으로 재분류 — **본 branch owned** (spa-ux D4 위임) | password 미설정 + 단일 federated identity → 엔진이 unlink 자동 거부. project 는 400 을 UX 로 처리(에러 안내 or 선제 "password 먼저 설정")만. 대안(프로젝트 자체 lockout 가드 구현) = 불필요(중복) | `raw/official-docs/keycloak-account-console-unlink-lockout-guard-official.md#KC-UNLINKGUARD-C1` (guard 조건 — 마지막 federated identity 제거 HTTP 400), `#KC-UNLINKGUARD-C2` (`isPasswordSet()` 구현), `#KC-UNLINKGUARD-C3` ("You can not remove last federated identity as you do not have a password.") | `official-vendor-doc` (엔진 소스코드 직접 근거) | keycloak `main` branch 기준(2026-07-15) — 배포 release tag 별 재확인 필요. narrative Admin Guide 미기재(소스코드가 유일 근거) |
|
||||
| D4 | Self-service link (Client Initiated Account Linking / `idp_link`) 시 기존 password 재확인 강제 — **공식 근거 없음 + 반대 근거 존재**. self-service link 는 role(`account.manage-account-links`) + 유효 access token 만 요구, password 재확인 미언급 | N/A (근거 부재로 보류). project 가 step-up 을 원하면 `idp_link` AIA 앞에 커스텀 재인증 삽입 필요 | `UNSUPPORTED_DECISION` — 반대 근거 `raw/official-docs/keycloak-client-initiated-account-linking.md#KC-CIAL-C2` (link 전제 = role + scope 만) | — | `idp_link` kc_action(AIA) 재인증 강제 여부 미조사(`needs-confirmation`) — 별도 branch |
|
||||
| D5 | Sync Mode 선택은 takeover 정책과 직교하며 이 branch가 값을 소유하지 않음 | D1의 persistent link key는 Sync Mode로 바뀌지 않는다. 값·선택 조건은 owner에서만 변경 | [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D3 — attribute synchronization policy owner | `delegated` | 원 결정문 "Scenario B 회피"는 인과 오류. 본 branch는 link key 불변식만 소유하고, email 값 충돌의 실제 동작은 `needs-confirmation` |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> *결정(Decisions)* 이 "*무엇*" 이라면 본 §는 "*어디에 어떻게*" 의 사전 명세. 본 브랜치는 `documented-only` 학습 노트이므로 구현 detail 은 **공식 문서가 규정하는 프로토콜·값 / 엔진 소스**를 anchor 로 하고 코드 미확인 항목은 `planned`/`UNSUPPORTED_IMPL_DECISION` 로 명시한다.
|
||||
> 본 branch 의 in-scope owned 구현 대상은 **D1(sub key 정책)** 과 **D3(unlink lockout — 엔진 확인)** 뿐. [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D2 — flow 구성 owner. [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D3 — Sync Mode owner. 본 branch는 두 결정을 재진술하지 않는다.
|
||||
|
||||
### 1. sub 기반 federated identity (link key) — D1
|
||||
|
||||
> **Trace**: D1 + `GOIDC-C3` + `KC-FLF-C2`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (a) `FEDERATED_IDENTITY` 테이블의 composite key/컬럼명(`FEDERATED_USER_ID` 에 sub 저장)은 본 Sources 미문서화 → Claims To Verify, `planned`. trade-off: 학습 단계엔 planned, 실 구현 시 DB/admin guide 확인. (b) OOTB `Create User If Unique` 는 **email/username** 으로 충돌 감지(`KC-FBLVERIFY-C5`) → sub-only 매칭엔 커스텀 authenticator 필요. 이 gap 은 본 branch 범위 밖(별도 branch, §Audit).
|
||||
|
||||
| 항목 | 값 / 명세 | 근거 |
|
||||
|---|---|---|
|
||||
| link key | Google `sub` → federated identity `FEDERATED_USER_ID` (Keycloak built-in) | D1, `GOIDC-C3` |
|
||||
| email 역할 | user attribute 만 (link key 아님). Attribute Importer 매핑 owner = [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D4 | D1 |
|
||||
| auto-link 금지 lever | First Broker Login "Automatically Link Existing Account"/AutoLink `DISABLED` (owner = [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D1) | delegate, `KC-FLF-C2`, `KC-FBLVERIFY-C4` |
|
||||
| 충돌 감지 방식 gap | OOTB 는 email/username 매칭 → sub-only 원하면 커스텀 authenticator (별도 branch) | `KC-FBLVERIFY-C5`, §Audit |
|
||||
|
||||
### 2. 기존 계정 link 시 재인증 관철 — D2 (정책 수준; flow 구성 delegate)
|
||||
|
||||
> **Trace**: D2 + `KC-FBLVERIFY-C1`/`C2`/`C3`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 정확한 authenticator step 값(`REQUIRED`/`ALTERNATIVE`/`DISABLED`)의 flow *구성* owner = [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D2. 본 branch 는 *정책*(secret 증명 필요)만 소유. trade-off: flow 편집 결정은 sibling.
|
||||
|
||||
| 항목 | 정책 / 명세 | 근거 |
|
||||
|---|---|---|
|
||||
| 기본 동작(주의) | SMTP 설정 realm → "Verify Existing Account By Email" 이 기본(`ALTERNATIVE`) 실행(secret 미증명) | `KC-FBLVERIFY-C1` |
|
||||
| password 재인증 관철 | admin 이 "Verify Existing Account By Email" DISABLE → "Verify Existing Account By Re-authentication" 실행 | `KC-FBLVERIFY-C2` / `KC-FBLVERIFY-C3` |
|
||||
| lockout 주의 | 비밀번호 미설정(Google-first) 사용자는 Re-authentication 불가 → 다른 연결 IdP 재인증 경로 or fallback 설계 필요 | `KC-FBLVERIFY-C3` |
|
||||
|
||||
### 3. self-service unlink lockout — D3 (엔진 강제; project 는 UX 만)
|
||||
|
||||
> **Trace**: D3 + `KC-UNLINKGUARD-C1`/`C2`/`C3`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 클라이언트 UX 처리 방식(에러 표시 vs 선제 안내)은 본 Sources 미규정 — project 선택. trade-off: 학습 단계엔 기본 Account Console 400 메시지 노출(`planned`).
|
||||
|
||||
| 항목 | 동작 | 근거 |
|
||||
|---|---|---|
|
||||
| 엔진 가드 | 마지막 federated identity 제거 시 `count>1 \|\| user.isFederated() \|\| isPasswordSet()` 아니면 HTTP 400 | `KC-UNLINKGUARD-C1`, `KC-UNLINKGUARD-C2` |
|
||||
| 사용자 메시지 | "You can not remove last federated identity as you do not have a password." | `KC-UNLINKGUARD-C3` |
|
||||
| project 작업 | 400 을 UX 로 처리(에러 안내 or 선제 "password 먼저 설정") — 보안 아닌 UX | D3 |
|
||||
| unlink 후 재로그인 | Federated Identity record 삭제 → 재로그인 시 First Broker Login 재실행 → §2 재인증 경로 | delegate [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D2 |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4 캡처용. 정상 경로 외에 *구현 중 부딪힐* 실패/엣지/다른 계약 의존을 미리 열거.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **Scenario A (email 재배정 takeover, §Takeover)**: 방어 = D1(sub key). `sub_B` ≠ `sub_A` → 신규 user. email 기반이면 탈취.
|
||||
- **Scenario B (self email 변경, §Takeover)**: 방어 = D1(sub key) — 이미 링크된 `sub_E` 는 재로그인 시 email 재매칭 없이 자기 계정으로 라우팅. **Sync Mode 무관**(§Audit 정정). FORCE 면 email attribute 값만 갱신 → takeover 아닌 **email 값 충돌** 리스크(realm "Duplicate emails" 설정 의존, `needs-confirmation`).
|
||||
- **Scenario C (email_verified=false, §Takeover)**: [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D4 — silent-link 방지 owner. [[raw/branch-notes/feature-keycloak-idp-brokering-google-client]] D6 — IdP email trust 설정 owner.
|
||||
- **orphan account unlink (D3)**: password 미설정 Google-only 사용자가 마지막 link 제거 → 엔진이 서버에서 HTTP 400 거부(`KC-UNLINKGUARD-C1`). project 는 400 UX 처리만.
|
||||
- **Google-first 가입 lockout (D2)**: 비밀번호 미설정 사용자가 두 번째 IdP 충돌 시 Re-authentication 재인증 수단 없어 막힐 수 있음(`needs-confirmation`).
|
||||
- **OOTB email-collision 매칭 (D1 tension)**: OOTB `Create User If Unique` 는 email/username 매칭(`KC-FBLVERIFY-C5`) → sub-only 매칭엔 커스텀 authenticator 필요(별도 branch, §Audit).
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D1 — silent auto-link 방지 owner. [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D2 — verification profile owner. [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D4 — hard-reject SPI 없이 성립하는 linking core owner.
|
||||
- [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D3 — Sync Mode owner. [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D4 — email attribute mapping owner.
|
||||
- [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]] D4 — unlink UX 진입점 owner. [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]] D5 — client-initiated linking owner.
|
||||
- [[raw/branch-notes/feature-keycloak-idp-brokering-google-client]] D6 — IdP email trust 설정 owner.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서 근거가 있어도 내 프로젝트에서의 동작을 자동으로 보장하지 않는다.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| Keycloak `FEDERATED_IDENTITY` 테이블의 composite key 는 `(IDENTITY_PROVIDER, FEDERATED_USER_ID, USER_ID)` 이며 FEDERATED_USER_ID 에 Google `sub` 가 저장됨 | 본 branch 의 Sources 는 Keycloak 내부 스키마를 다루지 않음. 본문 메모 자체가 "추정" 표기 | Keycloak DB 직접 조회 또는 official server-installation guide 의 schema 섹션 확인 | `needs-confirmation` |
|
||||
| OOTB `Create User If Unique` 가 email/username 이 아니라 `sub` 로 충돌 감지하게 하려면 커스텀 authenticator 가 필요하다 (D1 과 OOTB flow 의 구조적 긴장) | 조사에서 OOTB 는 "same email or username" 매칭 확인(`KC-FBLVERIFY-C5`) — sub-only 커스텀 구현 필요 여부는 미검증 | Keycloak `IdpCreateUserIfUniqueAuthenticator` 소스/SPI 문서 확인 + dev 환경에서 sub 충돌 재현 | `needs-confirmation` |
|
||||
| Google 이 실제로 primary email 변경을 허용하며 그 결과 ID token 의 `email` claim 이 변경된다 (Scenario B 의 전제) | GOIDC-C3 는 "email 변경 가능성" 을 함의하지만 Google primary email 변경의 정확한 정책 (alias vs primary) 은 본 인용 범위 밖 | Google Account 공식 help 페이지 추가 수집 또는 실제 dev Google 계정으로 변경 시도 | `needs-confirmation` |
|
||||
| `email_verified=false` 인 Google ID token 이 실제로 발급될 수 있는 시나리오가 존재 | 본 branch 의 Sources 는 `email_verified` semantics 의 정확한 조건을 직접 인용하지 않음 | Google OIDC `email_verified` claim 공식 spec 인용 추가 수집 | `needs-confirmation` |
|
||||
| Google Workspace 에서 퇴사자 email 이 신규 직원에게 재배정 가능 (Scenario A 의 전제) | codemancers tech-blog 만 언급 — `company-case-study` 등급 → 공식 best practice 로 단정 금지 | Google Workspace Admin 공식 문서 (user delete + recreate 정책) 인용 추가 수집 | `needs-confirmation` |
|
||||
| owner가 선택한 Sync Mode가 공식 semantics와 같은 attribute synchronization 결과를 내며 D1의 link key를 바꾸지 않는다 | 값 선택은 [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D3 소관이고, 본 branch는 takeover 직교성만 검증 | dev realm에서 owner profile 적용 후 attribute 변경·재로그인 결과와 federated identity key 불변을 함께 확인 | `needs-confirmation` (source-grounded) |
|
||||
| realm "Duplicate emails" 설정과 Sync Mode FORCE 의 상호작용 — FORCE 로 email 이 충돌 값으로 갱신될 때 실제 동작(성공/실패/무음 충돌) | 조사 범위 밖 — 공식 문서 미확인 영역 (Plan Gap) | Keycloak Admin Console > Realm Settings > "Duplicate emails" 값 확인 + dev realm 에서 email 충돌 재현 | `needs-confirmation` |
|
||||
| 인용한 엔진 가드/authenticator 동작이 실제 배포 Keycloak **release tag** 에서도 동일하다 | 인용 소스(`LinkedAccountsResource.java`, `first-login-flow.adoc`)는 keycloak `main` branch(2026-07-15) 기준 | 배포 예정 버전 tag 로 소스/문서 재확인 | `needs-confirmation` |
|
||||
| `idp_link` kc_action(Application Initiated Action) 이 재인증을 강제하는가 (D4 최종 답) | 조사에서 client-initiated linking 은 role+token 만 요구 확인(`KC-CIAL-C2`), `idp_link` AIA 는 미조사 | `IdpLinkAction` 소스 + Application Initiated Actions 공식 문서 조사 (별도 branch) | `needs-confirmation` |
|
||||
|
||||
## 감사와 발견 사항
|
||||
|
||||
> 2026-07-15 `/branch-spec` 자동조사(공식 문서 + 엔진 소스) 결과. 사용자 작성 결정은 verbatim 보존, 아래는 정합 권고·정정만 (CLAUDE.md §11).
|
||||
|
||||
- **RESOLVED — RESTATED_FOREIGN_DECISION**: 본 branch 의 core owned 결정은 **D1(sub key)** + **D3(unlink lockout safety — spa-ux D4 위임)** 뿐이다. **D2**는 [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D2, **D5**는 [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D3 를 Reference-Only로 consume하도록 정리했다.
|
||||
- **RESOLVED — RATIONALE_CORRECTION (D5)**: 원 결정문 "Sync Mode = IMPORT → Scenario B 회피" 는 Historical/superseded로 격리했다. Active D5는 D1이 takeover를 차단하고 Sync Mode는 직교한다는 lens와 owner pointer만 유지한다.
|
||||
- **CORRECTION (D2)**: 원 TODO/결정의 "Verify Existing Account by Re-authentication REQUIRED(기본)" 은 부정확 — SMTP 설정 realm 은 "Verify Existing Account By Email" 이 기본 `ALTERNATIVE`(`KC-FBLVERIFY-C1`). password 재인증을 관철하려면 admin 이 email authenticator 를 **명시적으로 DISABLE** 해야 한다(`KC-FBLVERIFY-C2`).
|
||||
- **UPGRADE (D3, UNSUPPORTED → CONFIRMED)**: D3 는 기존 `UNSUPPORTED_DECISION` 이었으나 엔진 소스(`LinkedAccountsResource#removeLinkedAccount` 의 `count>1 || user.isFederated() || isPasswordSet()` 가드 + `federatedIdentityRemovingLastProviderMessage`)로 **CONFIRMED**(`KC-UNLINKGUARD-C1`~`C3`). lockout 은 Keycloak 서버 가드가 이미 방지 → project 작업은 UX 처리로 축소. **narrative Admin Guide 미기재** — 소스코드가 유일 근거.
|
||||
- **CORRECTION (D4, UNSUPPORTED 유지 — 사유 격상)**: "self-service link 시 password 재확인" 은 근거 부재가 아니라 **확인된 반대 근거**(link 전제 = role + token 만, `KC-CIAL-C2`). project 가 이 정책을 원하면 커스텀 구현 필요.
|
||||
- **OUT_OF_BRANCH_SCOPE (이관 권고)**: OOTB `Create User If Unique` 는 email/username 으로 충돌 감지(`KC-FBLVERIFY-C5`, sub 아님). D1(sub-only linking)과 OOTB flow 의 **구조적 긴장** — sub-only 매칭엔 커스텀 authenticator 필요. 본 branch 범위 밖 → **별도 branch 로 이관 권고**(예: `feature-keycloak-sub-based-collision-authenticator`). 본 §에 이관 history 보존, §구현 가이드 §1 에는 gap 표시만.
|
||||
- **STALE_REVERSE_REF (Should-fix, `/sync` 대상)**: D3 가 `UNSUPPORTED_DECISION` → `official-vendor-doc` 로 승격됐으므로, 이를 참조하는 [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]] 의 D4/§구현 가이드/§엣지 (해당 노트에서 본 branch D3 를 `needs-confirmation`/`UNSUPPORTED` 로 요약한 참조들)이 stale. `/sync` 또는 `wiki-doc-author` mode=migrate 로 역참조 전파 필요(비차단).
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 아직 없음(문서 단계).
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/google-openid-connect-oidc]]
|
||||
- [[raw/official-docs/keycloak-account-console-unlink-lockout-guard-official]]
|
||||
- [[raw/official-docs/keycloak-first-broker-login-flow]]
|
||||
- [[raw/official-docs/keycloak-first-broker-login-verify-authenticators-official]]
|
||||
- [[raw/official-docs/keycloak-first-login-flow]]
|
||||
- [[raw/official-docs/keycloak-identity-provider-sync-mode-official]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
> 본 sub-sub-branch 는 leaf — 자식 자료 없음. Phase 3 P3A 실 구현 또는 외부 산출물 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 근거 자료
|
||||
|
||||
- [[raw/company-tech-blogs/keycloak-google-login-codemancers]]
|
||||
- [[raw/official-docs/keycloak-first-login-flow]]
|
||||
- [[raw/official-docs/keycloak-first-broker-login-verify-authenticators-official]] — D2 근거 (verify authenticators 기본 트리거 조건)
|
||||
- [[raw/official-docs/google-openid-connect-oidc]]
|
||||
- [[raw/official-docs/keycloak-identity-brokering-overview-official]]
|
||||
- [[raw/official-docs/keycloak-account-console-unlink-lockout-guard-official]] — D3 근거 (self-service unlink lockout guard, 엔진 소스)
|
||||
- [[raw/official-docs/keycloak-identity-provider-sync-mode-official]] — D5 근거 (Sync Mode 공식 의미론)
|
||||
- [[raw/official-docs/keycloak-client-initiated-account-linking]] — D4 반대 근거
|
||||
|
||||
### 오류 기록 (이 sub-sub-branch 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 documented-only 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — Phase 3 실 구현 단계에 누적)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크: (없음, 문서까지만)
|
||||
- 머지 결과 / 배포 환경: 없음 (`documented-only`)
|
||||
- **wiki 추출 대상:** 없음. P1B sub-sub-branch는 root 정책상 wiki/projects/ 승급 안 함.
|
||||
- **추출하지 않을 항목:** 본 sub-sub-branch 전체 (`documented-only` / `planned`).
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
---
|
||||
title: branch / feature-keycloak-bff-csrf-samesite-defense
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-PATTERNS-OVERVIEW-011
|
||||
kind: project-work-item
|
||||
project: keycloak-patterns-overview
|
||||
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-011
|
||||
inherits: [DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1, DEC-KEYCLOAK-PATTERNS-OVERVIEW-BFF-SESSION-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-KEYCLOAK-PATTERNS-OVERVIEW-010]
|
||||
imports: []
|
||||
delegates: []
|
||||
accepts_delegations: []
|
||||
contract_packet: 1
|
||||
contract_packet_sha256: 00ce3ab254b077628852e877be348df58e6c0d84b03466a875ecc7a3c4023e11
|
||||
branch: feature-keycloak-bff-csrf-samesite-defense
|
||||
parent_branch:
|
||||
related_projects: [keycloak-patterns-overview]
|
||||
tags: [branch]
|
||||
created: 2026-07-23
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-bff-csrf-samesite-defense
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- [[raw/project-notes/keycloak-patterns-overview]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: CSRF를 재현하고 SameSite와 CSRF token 적용 후 403을 검증한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1` | done-bar는 E2E success와 signature security failure 재현·해결 evidence다 | `WI-KEYCLOAK-PATTERNS-OVERVIEW-011` 완료 조건에 적용 | `[[raw/project-notes/keycloak-patterns-overview]]` |
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-BFF-SESSION-001@1` | AP3는 token을 backend session에 두고 browser에는 cookie만 둔다 | `WI-KEYCLOAK-PATTERNS-OVERVIEW-011` 완료 조건에 적용 | `[[raw/project-notes/keycloak-patterns-overview]]` |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- GENERATED: artifact-imports:start -->
|
||||
### 가져온 artifact 계약
|
||||
|
||||
| Artifact Ref | Owner | Producer | Schema Ref |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: artifact-imports:end -->
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
<!-- GENERATED: received-delegations:start -->
|
||||
### 수신한 위임
|
||||
|
||||
| Delegation Ref | From | Concern | Status |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: received-delegations:end -->
|
||||
|
||||
<!-- GENERATED: flow:start -->
|
||||
### 가져온 흐름 단계
|
||||
|
||||
| Stage Ref | Order | Owner | Input | Action | Output |
|
||||
|---|---:|---|---|---|---|
|
||||
<!-- GENERATED: flow:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
- `WI-KEYCLOAK-PATTERNS-OVERVIEW-011`의 완료 조건을 구현한다: CSRF를 재현하고 SameSite와 CSRF token 적용 후 403을 검증한다
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- Work Item 완료 조건
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- project decision registry 변경
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
외부 근거 미등록. `/branch-spec feature-keycloak-bff-csrf-samesite-defense` 단계에서 source claim을 연결한다.
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] CSRF를 재현하고 SameSite와 CSRF token 적용 후 403을 검증한다 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
project 결정 외 branch-local 결정은 아직 없음.
|
||||
|
||||
<!-- section-id: decision-evidence -->
|
||||
## 결정-근거 매핑
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: implementation -->
|
||||
## 구현 가이드
|
||||
|
||||
`/branch-spec` 단계에서 source claim 기반으로 작성한다.
|
||||
|
||||
<!-- section-id: edge-failure-dependency -->
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**: `/branch-spec` 단계에서 구체화한다.
|
||||
- **다른 계약 의존**: `WI-KEYCLOAK-PATTERNS-OVERVIEW-010`
|
||||
|
||||
<!-- section-id: claims-to-verify -->
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
`/coverage` 실행 전.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
해당 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
---
|
||||
title: branch / feature-keycloak-bff-oauth2login-session
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-PATTERNS-OVERVIEW-010
|
||||
kind: project-work-item
|
||||
project: keycloak-patterns-overview
|
||||
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-010
|
||||
inherits: [DEC-KEYCLOAK-PATTERNS-OVERVIEW-BFF-SESSION-001@1, DEC-KEYCLOAK-PATTERNS-OVERVIEW-SECRET-BOUNDARY-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-KEYCLOAK-PATTERNS-OVERVIEW-002]
|
||||
imports: []
|
||||
delegates: []
|
||||
accepts_delegations: []
|
||||
contract_packet: 1
|
||||
contract_packet_sha256: 7aec2d980997db5ff4dc83374809ecc4a503b3b46665c6cea8bdd3114c1a9f0b
|
||||
branch: feature-keycloak-bff-oauth2login-session
|
||||
parent_branch:
|
||||
related_projects: [keycloak-patterns-overview]
|
||||
tags: [branch]
|
||||
created: 2026-07-24
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-bff-oauth2login-session
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- [[raw/project-notes/keycloak-patterns-overview]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: browser token 0개와 SESSION cookie만으로 BFF proxy API 200이 재현된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-BFF-SESSION-001@1` | AP3는 token을 backend session에 두고 browser에는 cookie만 둔다 | `WI-KEYCLOAK-PATTERNS-OVERVIEW-010` 완료 조건에 적용 | `[[raw/project-notes/keycloak-patterns-overview]]` |
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-SECRET-BOUNDARY-001@1` | confidential client secret은 env var로 주입하고 commit·realm export 평문을 금지한다 | `WI-KEYCLOAK-PATTERNS-OVERVIEW-010` 완료 조건에 적용 | `[[raw/project-notes/keycloak-patterns-overview]]` |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- GENERATED: artifact-imports:start -->
|
||||
### 가져온 artifact 계약
|
||||
|
||||
| Artifact Ref | Owner | Producer | Schema Ref |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: artifact-imports:end -->
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
<!-- GENERATED: received-delegations:start -->
|
||||
### 수신한 위임
|
||||
|
||||
| Delegation Ref | From | Concern | Status |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: received-delegations:end -->
|
||||
|
||||
<!-- GENERATED: flow:start -->
|
||||
### 가져온 흐름 단계
|
||||
|
||||
| Stage Ref | Order | Owner | Input | Action | Output |
|
||||
|---|---:|---|---|---|---|
|
||||
<!-- GENERATED: flow:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
- `WI-KEYCLOAK-PATTERNS-OVERVIEW-010`의 완료 조건을 구현한다: browser token 0개와 SESSION cookie만으로 BFF proxy API 200이 재현된다
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- Work Item 완료 조건
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- project decision registry 변경
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
외부 근거 미등록. `/branch-spec feature-keycloak-bff-oauth2login-session` 단계에서 source claim을 연결한다.
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] browser token 0개와 SESSION cookie만으로 BFF proxy API 200이 재현된다 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
project 결정 외 branch-local 결정은 아직 없음.
|
||||
|
||||
<!-- section-id: decision-evidence -->
|
||||
## 결정-근거 매핑
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: implementation -->
|
||||
## 구현 가이드
|
||||
|
||||
`/branch-spec` 단계에서 source claim 기반으로 작성한다.
|
||||
|
||||
<!-- section-id: edge-failure-dependency -->
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**: `/branch-spec` 단계에서 구체화한다.
|
||||
- **다른 계약 의존**: `WI-KEYCLOAK-PATTERNS-OVERVIEW-002`
|
||||
|
||||
<!-- section-id: claims-to-verify -->
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
`/coverage` 실행 전.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
해당 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
---
|
||||
title: branch / feature-keycloak-bff-vs-spa-direct (BFF 대안 비교 — SPA Direct vs Backend-for-Frontend)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-CHILD-2BFCDCAB
|
||||
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-bff-vs-spa-direct
|
||||
parent_branch: feature-keycloak-patterns
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, p2a, bff, spa, xss-surface, session, oauth2-login]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: f0b2fdad99f29c8e633580dc9d5b65c5b88879f90ace63f773a6ded847306d5f
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-bff-vs-spa-direct — BFF 대안 비교
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/branch-notes/feature-keycloak-patterns]]의 branch-child.
|
||||
> **목적**: P2A(SPA Direct, 토큰을 SPA가 보유) vs BFF(Backend-for-Frontend, 토큰을 백엔드가 보유)의 **XSS surface 차이**와 stateful trade-off를 명확히 정리.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
|
||||
<!-- 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 변형이다 | SPA Direct와 BFF 비교를 AP taxonomy의 대안 근거로 유지한다 | [[raw/project-notes/keycloak-patterns-overview]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
| D1 | 학습 baseline은 SPA Direct로 둔다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
| D2 | BFF 구현은 비교 문서 범위로 제한한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
| D3 | native client는 별도 PKCE 흐름이 필요함을 기록한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
| D4 | browser token XSS surface를 BFF motivation으로 기록한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
| D5 | BFF 권고의 적용 조건을 client credential 사용 여부로 제한한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
없음.
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
P2A SPA Direct는 OIDC + PKCE의 canonical 흐름이지만 **토큰이 브라우저(JS 컨텍스트)에 존재**한다는 근본적 위험이 있다. BFF는 이 위험을 제거하는 변형 — 백엔드가 OAuth client 역할을 하고, 브라우저는 httpOnly session cookie만 보유. Curity 등 보안 벤더가 권고하는 패턴.
|
||||
|
||||
핵심 질문:
|
||||
|
||||
- BFF 아키텍처에서 토큰이 흐르는 경계는? 누가 보관하는가?
|
||||
- Spring Security `oauth2Login` + session vs Spring Authorization Server (AS 자체 구축) 차이?
|
||||
- BFF 단점은? (stateful, scale-out 시 session sharing 필요)
|
||||
- 어떤 기준으로 SPA Direct vs BFF를 결정하는가? (XSS 민감도 / 모바일 클라이언트 유무 / 운영 복잡도)
|
||||
|
||||
본 sub-sub-branch는 **아키텍처 다이어그램 + Spring 구현 옵션 + 결정 기준 매트릭스**를 정리.
|
||||
|
||||
- 이슈: (학습 노트, 이슈 없음)
|
||||
- PR: (구현 없음)
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- BFF(Backend-for-Frontend)와 SPA Direct(P2A)의 **XSS surface 차이** 정리 — 토큰이 브라우저(JS 컨텍스트)에 있는가 vs 백엔드에 있는가의 경계
|
||||
- BFF 아키텍처 다이어그램 + 토큰 흐름 경계(누가 access/refresh token holder 인가)
|
||||
- Spring Security `oauth2Login` (BFF) 구현 옵션의 **사전 명세** — `documented-only` (실 구현 아님, §구현 가이드)
|
||||
- **SPA Direct vs BFF 결정 기준 매트릭스** — "언제 어느 패턴" 선택 조건 (XSS 민감도 / 모바일 클라이언트 유무 / 백엔드 stateless / 운영 복잡도 / revocation 즉시성 / OAuth 2.1의 client-credentials 조건부 권고)
|
||||
- Curity(company-tech-blog) + OAuth 2.1 draft(official-standard) 인용으로 BFF motivation 근거화
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- **BFF 실 구현/배포** — 문서화만. `documented-only` 유지(D2). P3A 실 구현 이후 XSS 민감 요구 발생 시 별도 확장 branch
|
||||
- **Spring Authorization Server (AS 자체 구축)** — Keycloak 대체 프로젝트로 BFF 결정과 직교. Keycloak을 AS로 두고 `oauth2Login`만으로 BFF 성립하므로 본 학습 범위 밖
|
||||
- **P1A Edge ForwardAuth 상세** — 형제 [[raw/branch-notes/feature-keycloak-edge-forwardauth-no-google]] 소관 (본 branch는 BFF와의 개념 구분만)
|
||||
- **SPA Direct 토큰 저장 위치 상세** — 형제 [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] 소관
|
||||
- **refresh token rotation/revocation 상세** — 형제 [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] 소관
|
||||
- **모바일/native OAuth client 실 흐름** — RFC 8252 public-client PKCE 흐름 자체의 구현. 본 branch는 "BFF가 모바일을 커버 못 함"의 **한계 명시**까지만(D3)
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
- [[raw/company-tech-blogs/curity-bff-pattern-spa]] — Curity BFF pattern article (메인 근거)
|
||||
- [[raw/official-docs/oauth-v2-1-draft-ietf]] — OAuth 2.1 draft (browser app이 client credentials를 사용하려는 경우의 BFF 조건부 권고)
|
||||
- [[raw/official-docs/spring-security-resource-server-jwt]] — Spring Security (SPA Direct 측 비교 reference)
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [ ] **BFF 아키텍처 다이어그램** — 등급: `planned`
|
||||
```text
|
||||
┌─────────────────┐
|
||||
│ Keycloak │
|
||||
└────────▲────────┘
|
||||
│ OIDC (server-side)
|
||||
│ access/refresh
|
||||
│ token 보유
|
||||
┌────────┴────────┐
|
||||
Browser (SPA) ── httpOnly session ────► │ BFF (Backend) │ ── Bearer token ──► Resource API
|
||||
cookie (JSESSIONID 등) │ - session store│ (BFF가 token
|
||||
│ - token cache │ holder)
|
||||
└─────────────────┘
|
||||
```
|
||||
- 브라우저: 토큰 0개, session cookie만
|
||||
- BFF: OAuth client 역할 + session ↔ token mapping 보관 (in-memory / Redis)
|
||||
- [ ] **Spring Security `oauth2Login` 구현 옵션** — 등급: `documented-only`
|
||||
- 의존성: `spring-boot-starter-oauth2-client`
|
||||
- `application.yml`:
|
||||
```yaml
|
||||
spring:
|
||||
security:
|
||||
oauth2:
|
||||
client:
|
||||
registration:
|
||||
keycloak:
|
||||
client-id: bff-client
|
||||
client-secret: <secret>
|
||||
authorization-grant-type: authorization_code
|
||||
redirect-uri: "{baseUrl}/login/oauth2/code/keycloak"
|
||||
scope: openid, profile, email
|
||||
provider:
|
||||
keycloak:
|
||||
issuer-uri: https://<keycloak>/realms/<realm>
|
||||
```
|
||||
- `http.oauth2Login(...)` + `http.sessionManagement(...)` (stateful session)
|
||||
- 백엔드가 자동으로 authorization code flow 수행 + session 생성 + `OAuth2AuthorizedClient`에 토큰 보관
|
||||
- [ ] **Spring Authorization Server 대안** — 등급: `documented-only`
|
||||
- Spring Authorization Server는 **AS 자체를 직접 구축**하는 프로젝트 (Keycloak 대체). BFF와 직교한 결정.
|
||||
- BFF 본질은 "백엔드가 OAuth client" — Keycloak을 AS로 두고 Spring `oauth2Login`만으로 충분.
|
||||
- 본 P2A 학습 범위 외 (Keycloak 대체 안 함)
|
||||
- [ ] **BFF 단점** — 등급: `documented-only`
|
||||
- **Stateful**: session store 필요 → scale-out 시 sticky session 또는 Redis 등 외부 session store
|
||||
- **모바일 클라이언트**: BFF는 web SPA 전용. 모바일은 별도 OAuth client 흐름 필요 → BFF가 모바일까지 커버하려면 추가 endpoint 설계
|
||||
- **CSRF surface 증가**: session cookie 자동 첨부 → CSRF token 또는 SameSite 필요
|
||||
- **운영 복잡도**: session store 장애 시 전체 로그인 무효화
|
||||
- [ ] **결정 기준 매트릭스** — 등급: `documented-only`
|
||||
| 기준 | SPA Direct (P2A) 우위 | BFF 우위 |
|
||||
|------|----------------------|----------|
|
||||
| XSS 민감도 (금융/의료) | — | ✅ |
|
||||
| 모바일/네이티브 동일 흐름 | ✅ | — |
|
||||
| 백엔드 stateless 유지 | ✅ | — |
|
||||
| 운영 단순성 (session store 불필요) | ✅ | — |
|
||||
| 토큰 revocation 즉시성 | — | ✅ (session 종료) |
|
||||
| OAuth 2.1 draft 권고 | ✅ public client + PKCE | ✅ client credentials가 필요한 browser app (D5) |
|
||||
| 다중 backend microservice | ✅ (각자 JWT 검증) | △ (BFF가 fan-out) |
|
||||
- [ ] **Curity / OAuth 2.1 draft 인용** — 등급: `documented-only`
|
||||
- Curity: *"The only way to protect tokens from being accessed by any malicious code is to keep them away from the browser."*
|
||||
- OAuth 2.1 draft §2.1: browser-based app 이 **client credentials 를 사용하려는 경우** BFF 패턴을 **권고** (`OA21-C4` — "browser-based app 전반 의무" 아님, 조건부)
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
작업하며 떠오른 메모. 자유 형식.
|
||||
|
||||
- BFF는 "토큰을 백엔드에 두는 OAuth client" 패턴. P1A(Edge ForwardAuth)와 헷갈리기 쉬운데 두 가지가 다름:
|
||||
- P1A는 reverse proxy가 인증 검문소(별도 컴포넌트 oauth2-proxy)
|
||||
- BFF는 application backend 자체가 OAuth client + session holder
|
||||
- Spring Security `oauth2Login`은 본질적으로 BFF 패턴을 자동 구현해 줌. SPA Direct와 다른 starter(`oauth2-client` vs `oauth2-resource-server`)를 쓴다는 점이 명확한 분기점.
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
> 추후 면접/회고에서 "왜 이렇게 했나" 답할 근거. 대안과 함께 기록.
|
||||
|
||||
- 2026-05-25: 본 keycloak-patterns 프로젝트는 **SPA Direct (P2A)를 학습 목적의 1순위**로 채택. BFF는 비교 문서로만 정리. 이유: canonical OIDC + PKCE 흐름을 먼저 이해하는 것이 목표.
|
||||
- 2026-05-25: BFF 실 구현은 본 sub-sub-branch 범위 외 — SSOT §8 자신 없는 부분에 BFF 미경험으로 명시되어 있고, P3A 구현 이후 별도 확장 시 고려.
|
||||
- 2026-05-25: BFF의 모바일 한계는 분명히 기록 (P2A 형제 branch에서 다중 클라이언트 장점을 활용한 결정과 연결).
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지. company-tech-blog 인 Curity 는 `company-case-study` 강도이며, OAuth 2.1 draft (`official-standard`) 와 Keycloak 공식 doc (`official-vendor-doc`) 으로만 official best practice 단언 가능. 단독 company-tech-blog 만으로는 official 단언 금지.
|
||||
|
||||
> `선택 조건` 열(R2): "이 조건일 때 이 결정, 다른 조건이면 어떤 대안". 본 branch 는 SPA Direct vs BFF 선택 자체가 주제이므로 각 결정의 선택 기준을 명시한다.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | 본 keycloak-patterns 프로젝트는 SPA Direct (P2A) 를 학습 1순위로 채택, BFF 는 비교 문서로만 정리 | **canonical OIDC + PKCE 흐름 학습이 1차 목표**일 때 SPA Direct. XSS 민감 데이터(금융/의료) 운영 요구가 우선이면 BFF 를 1순위로 전환 | `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C1` (PKCE MUST — canonical SPA Direct 흐름), `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C4` (BFF 권고는 client credentials 사용 시) | `official-standard` | P2A SPA Direct 가 OAuth 2.1 §2.1 의 "client credentials 없는 public client + PKCE" 시나리오에 정합한지 본 프로젝트 client 설정 (`Standard Flow + Public + PKCE S256`) 으로 실 검증 필요 |
|
||||
| D2 | BFF 실 구현은 본 sub-sub-branch 범위 외 (`documented-only` 유지) | **학습 우선순위/시간 제약** 하에서는 문서화만. P3A 실 구현 완료 + XSS 민감 요구 발생 시 별도 확장 branch 로 실 구현 | UNSUPPORTED_DECISION (운영 결정 — 학습 우선순위 / 시간 제약 사유, 외부 자료가 직접 뒷받침하지 않음) | UNSUPPORTED_DECISION | 미구현 상태에서 면접/포트폴리오에 BFF 경험을 주장하면 거짓. 본 branch 의 모든 BFF 관련 등급은 `documented-only` 로 유지해야 함 |
|
||||
| D3 | BFF 의 모바일 한계 (모바일은 별도 OAuth client 흐름 필요) 를 명시적으로 기록 | **web SPA 단일 클라이언트**면 BFF 성립. 모바일/native 클라이언트가 공존하면 native 는 별도 public-client PKCE 흐름(RFC 8252)이 MUST → BFF 단독으로 커버 불가, SPA Direct 가 다중 클라이언트에 유리 | `raw/official-docs/security-oauth2-pkce-rfc-8252.md#RFC8252-C1` (native public client 는 자체 PKCE 흐름 MUST — "별도 흐름 필요" 절반을 corroborate) | `official-standard` (부분 — "모바일은 별도 흐름 필요"만 근거; "BFF 가 모바일에서 동작 불가"는 여전히 추론) | RFC8252-C1 은 native 가 자체 PKCE 흐름을 MUST 사용함을 보장할 뿐, "BFF session cookie 모델이 모바일에서 동작 안 한다"는 절대 표현은 직접 없음. 모바일 SDK 측 cookie 처리 / native browser handoff 는 별도 검증(Claims To Verify) 필요 |
|
||||
| D4 | "토큰을 브라우저 밖에 두는 것이 XSS 로부터 보호하는 방법" 이라는 BFF motivation 인용 | XSS 위협 모델이 유의(브라우저에 token 존재 = 탈취 표면)한 SPA 일 때 이 motivation 이 BFF 채택 근거. XSS 표면이 무의미할 만큼 통제(CSP/sanitization)되면 SPA Direct 도 허용 | `raw/company-tech-blogs/curity-bff-pattern-spa.md#CURITY-BFF-C1`, `raw/company-tech-blogs/curity-bff-pattern-spa.md#CURITY-BFF-C3`, `raw/company-tech-blogs/curity-bff-pattern-spa.md#CURITY-BFF-C4` | `company-case-study` | Curity 는 vendor 이며 본 인용은 official best practice 가 아님. "유일한 방법" 표현은 vendor 의 강한 주장 — OAuth 2.1 `OA21-C4` 로만 official 권고 corroborate 가능 |
|
||||
| D5 | OAuth 2.1 draft 가 browser-based app 에서 BFF 패턴을 권고한다는 진술 | **SPA 가 client credentials 를 사용**하려는 경우(§2.1 조건)에 BFF 권고. public client + PKCE 만이면 SPA Direct 도 표준 허용 — "browser-based app 전반 의무" 아님 | `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C4` | `official-standard` | `OA21-C4`의 조건은 source-grounded이나, 본 프로젝트는 confidential BFF client와 client-secret runtime을 아직 구현하지 않았다. 실제 BFF 선택·동작 evidence는 `documented-only`다 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 본 branch 는 `documented-only` 비교/학습 branch — 실행 코드가 아니라 **BFF 대안의 사전 명세 + 결정 기준 매트릭스의 근거 매핑**이 산출물이다. 아래 in-scope 항목은 D1·D4·D5 결정의 도출이며, 소스가 원칙만 권고하고 detail 을 사용자가 정해야 하는 cell 은 `UNSUPPORTED_IMPL_DECISION` 라벨을 단다. 실 구현(코드) 등급은 모두 `planned`.
|
||||
|
||||
### 1. 토큰 holder 명세
|
||||
|
||||
> **Trace**: D4 (Curity BFF motivation — `CURITY-BFF-C1`/`C3`/`C4`) + D5 (OAuth 2.1 조건부 권고 — `OA21-C4`). BFF 의 핵심은 토큰이 흐르는 경계와 holder 를 SPA Direct 대비 이동시키는 것.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: session store 백엔드(in-memory vs Redis)는 소스 미권고 — scale-out 요구에 따른 사용자 결정. trade-off: 학습 문서라 단일 인스턴스 in-memory 가정으로 충분, HA 필요 시 Redis 로 승격.
|
||||
|
||||
| 경계 | 무엇을 보유 | 메커니즘 | 근거 | 등급 |
|
||||
|---|---|---|---|---|
|
||||
| 브라우저 (SPA) | 토큰 0개, httpOnly session cookie(JSESSIONID 등)만 | BFF 가 발급한 session cookie 로 세션 식별 | `CURITY-BFF-C4` (OAuth Agent 가 httpOnly session cookie 발급) | `documented-only` |
|
||||
| BFF (백엔드) | access/refresh token + session↔token mapping | server-side authorization code flow 수행 후 서버 메모리/store 에 보관 | `CURITY-BFF-C3` (모든 통신이 backend OAuth Agent 경유, token 은 SPA 미도달) | `documented-only` |
|
||||
| BFF ↔ Keycloak | — (server-to-server) | server-side `authorization_code` flow, access/refresh 서버 보유 | `OA21-C4` (client credentials 시 BFF 권고) | `documented-only` |
|
||||
| BFF ↔ Resource API | BFF 가 보유 token 을 Bearer 로 fan-out | `OAuth2AuthorizedClient` 의 access token 을 downstream 호출에 첨부 | `UNSUPPORTED_IMPL_DECISION` — Curity "OAuth Agent" 의 Spring 대응이 `OAuth2AuthorizedClient` 인지 미확정(Claims To Verify #4). trade-off: Spring 표준 API 로 가정, vendor 1:1 대응은 미검증 | `planned` |
|
||||
|
||||
### 2. Spring Security `oauth2Login` (BFF) 구성 사전 명세
|
||||
|
||||
> **Trace**: D5 (`OA21-C4` — BFF 권고) + D4 (`CURITY-BFF-C4` — session cookie 모델). BFF 를 Spring 으로 구현하면 SPA Direct 의 `oauth2-resource-server` 대신 `oauth2-client` starter 를 쓴다는 것이 명확한 분기점(진행 중 메모).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `client-id: bff-client`·`scope`·`redirect-uri` 의 구체 값은 소스가 아니라 배포 환경이 정함. trade-off: 본 명세는 형태(shape)만 확정, 값은 실 realm 등록 시점에 채움.
|
||||
|
||||
| 항목 | 명세 | 근거 | 등급 |
|
||||
|---|---|---|---|
|
||||
| 의존성 | `spring-boot-starter-oauth2-client` (SPA Direct 의 `-resource-server` 와 대비되는 분기점) | 진행 중 메모 + D5 | `documented-only` |
|
||||
| flow wiring | `http.oauth2Login(...)` + `http.sessionManagement(...)` — 백엔드가 authorization code flow 자동 수행 + session 생성 + `OAuth2AuthorizedClient` 에 토큰 보관 | D5 (`OA21-C4`) | `documented-only` |
|
||||
| 브라우저 세션 | `oauth2Login` 이 인증 후 httpOnly session cookie 발급 (Curity 의 OAuth Agent 역할과 동등) | `CURITY-BFF-C4` | `documented-only` |
|
||||
| `application.yml` | `registration.keycloak` (client-id/secret/authorization_code/redirect-uri) + `provider.keycloak.issuer-uri` — 값은 `UNSUPPORTED_IMPL_DECISION` | D5 | `planned` |
|
||||
| 실 동작 검증 | Boot 3.x 에서 `/login/oauth2/code/keycloak` callback 200 + session cookie 발급 확인 | Claims To Verify #1 | `planned` |
|
||||
|
||||
### 3. SPA Direct vs BFF 결정 기준 매트릭스 — 셀별 근거 매핑
|
||||
|
||||
> **Trace**: D1 (SPA Direct 채택) + D5 (조건부 BFF 권고). 본 매트릭스가 "언제 어느 패턴" 선택 조건의 근거. §TODO 의 매트릭스(원본 표) 각 셀을 supporting claim 또는 `UNSUPPORTED_IMPL_DECISION` 으로 분해 — Claims To Verify #5(셀→claim 매핑 `planned`)를 종결.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: "백엔드 stateless"·"운영 단순성"·"다중 microservice"·"revocation 즉시성" 셀은 본 branch Sources 에 직접 인용이 없는 **아키텍처 분석 통찰**이다. trade-off: 일반 원리(BFF=stateful session store / JWT=stateless revocation 난이도)로 성립하나 official 단정 불가 — 형제 branch 결정에 위임(§엣지·실패·의존).
|
||||
|
||||
| 매트릭스 셀 | 우위 | 뒷받침 근거 | 판정 |
|
||||
|---|---|---|---|
|
||||
| XSS 민감도 (금융/의료) | BFF | `CURITY-BFF-C1` (token 브라우저 밖 = XSS 보호), `CURITY-BFF-C2` (SPA 악성코드가 token read 가능), `CURITY-BFF-C6` (refresh token 탈취 위험) | company-case-study |
|
||||
| 모바일/네이티브 동일 흐름 | SPA Direct | `RFC8252-C1` (native 는 자체 public-client PKCE 흐름 — SPA Direct token 흐름 재사용 가능, BFF session cookie 는 부적합) | official-standard (부분) |
|
||||
| 백엔드 stateless 유지 | SPA Direct | `UNSUPPORTED_IMPL_DECISION` — BFF 는 session store 필요(stateful)라는 일반 원리. `CURITY-BFF-C4` 의 "session cookie 발급"이 stateful 함의를 뒷받침하나 직접 단정은 아님 | 분석 통찰 |
|
||||
| 운영 단순성 (session store 불필요) | SPA Direct | `UNSUPPORTED_IMPL_DECISION` — 위와 동일(session store 유무) | 분석 통찰 |
|
||||
| 토큰 revocation 즉시성 | BFF (session 종료) | `UNSUPPORTED_IMPL_DECISION` — JWT stateless = revocation 난이도는 형제 [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] 소관. 본 branch 직접 인용 없음 | 위임 |
|
||||
| OAuth 2.1 draft 권고 | BFF (조건부) | `OA21-C4` (client credentials 사용 시 BFF 권고 — 무조건 아님) | official-standard |
|
||||
| 다중 backend microservice | SPA Direct (각자 JWT 검증) | `UNSUPPORTED_IMPL_DECISION` — 각 RS 의 aud 검증은 형제 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 소관. 본 branch 직접 인용 없음 | 위임 |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4(깊이 게이트) 캡처용. BFF 는 SPA Direct 대비 stateful 로 전환되므로 정상 경로 밖의 실패/엣지가 늘어난다. 본 branch 는 `documented-only` 이나, 실 구현 시 부딪힐 실패 경로와 형제 계약 의존을 미리 열거한다.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **session store 장애 → 전체 로그인 무효화**: BFF 는 session↔token mapping 을 보유(§구현 가이드 §1)하므로 store 장애 시 모든 활성 세션 유실. 기대 동작: 외부 session store(Redis) HA 또는 sticky session. 근거: `CURITY-BFF-C4` 의 session cookie 모델(D4).
|
||||
- **CSRF surface 증가**: session cookie 는 브라우저가 자동 첨부 → CSRF 취약. 기대 동작: CSRF token(동기화 토큰) 또는 `SameSite=Lax/Strict` cookie 속성 필수. `CURITY-BFF-C4` 는 "session cookie 발급"만 보장하고 CSRF 통제는 미언급 — 별도 명시 필요.
|
||||
- **scale-out 시 session sharing**: 다중 BFF 인스턴스면 session 공유(Redis) 필수. sticky session 은 인스턴스 장애 시 해당 세션 유실. `UNSUPPORTED_IMPL_DECISION`(§구현 가이드 §3 "백엔드 stateless" 셀과 동일 원리).
|
||||
- **모바일 클라이언트 handoff**: BFF session cookie 모델은 native 앱에 부적합. native 는 `RFC8252-C1` 의 public-client PKCE 별도 흐름이 MUST(D3). BFF 로 모바일까지 커버하려면 추가 endpoint 설계 필요.
|
||||
- **Keycloak 미가용**: BFF 는 server-side authorization code flow 로 token 을 획득하므로 로그인 시점 Keycloak 장애 → 신규 로그인 차단(기존 세션은 BFF 보유 token 만료 전까지 유지). SPA Direct 와 달리 브라우저가 직접 Keycloak 을 치지 않음.
|
||||
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]] (P2A parent) 의 `D1`(SPA Direct = 브라우저 token 보유 정의) — 본 비교의 SPA Direct 기준선. 그 정의가 바뀌면 본 매트릭스 전체가 영향.
|
||||
- [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] D1 — pure SPA는 access/refresh 모두 memory-only이며 reload 시 재인증한다. HttpOnly refresh cookie는 TMB/BFF variant일 때만 평가하며, 본 매트릭스의 SPA Direct 기준선은 owner 결론을 consume한다.
|
||||
- [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] 의 `D1`(rotation 활성화 — reuse detection) + `D2`(access token revocation = 짧은 TTL 로 해결) — 매트릭스 "revocation 즉시성" 셀(§구현 가이드 §3, `UNSUPPORTED_IMPL_DECISION` 위임)이 이 결정에 의존. 그 branch 가 rotation 정책을 바꾸면 SPA Direct 의 revocation 약점 평가가 달라짐.
|
||||
- [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 의 `D1`(iss+sig+exp+aud 4종 검증, aud 는 custom validator 필수) + `D4`(SPA client scope 에 Audience mapper 등록 필수) — 매트릭스 "다중 microservice" 셀(위임)이 각 RS 의 aud 검증에 의존. fan-out 시 각 downstream 이 자기 client 를 aud 로 검증해야 cross-client reuse 방지.
|
||||
- [[raw/branch-notes/feature-keycloak-edge-forwardauth-no-google]] (P1A) — BFF 와 개념 혼동 방지(진행 중 메모). P1A = 별도 reverse proxy 가 인증 검문소, BFF = application backend 자체가 OAuth client. 계약 의존은 아니나 경계 구분 유지 필요.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서 / 사례는 근거지만, 본 프로젝트의 실제 동작은 자동 보장되지 않는다. 구현 전후 검증 항목.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| Spring Security `oauth2Login` (`spring-boot-starter-oauth2-client`) 가 본 branch 본문 yml 설정 그대로 Keycloak 과 authorization code flow 를 성공시키는지 | 본 branch 의 yml 은 `documented-only` 단계 — 실 구현 없음. starter 버전 / Spring Boot 3.x compat 확인 안 됨 | 실제 Spring Boot 3.x project 에 의존성 추가 + `application.yml` 적용 후 `/login/oauth2/code/keycloak` callback 200 확인, session cookie 발급 확인 | `planned` |
|
||||
| Keycloak 의 client 설정 (Standard Flow + Public + PKCE S256) 이 P2A SPA Direct 와 정합한지 | 본 branch 는 BFF 비교만 다루고 P2A client 설정의 실 등록을 안 했음 | Keycloak realm export → client config JSON 에서 `standardFlowEnabled=true`, `publicClient=true`, `attributes.pkce.code.challenge.method=S256` 확인 | `needs-confirmation` |
|
||||
| BFF 가 모바일 클라이언트에서 실제로 동작 불가한지 (또는 별도 흐름이 정확히 필요한지) | 본 branch Sources 에 직접 인용 없음 — 본문 통찰만 | RFC 8252 (OAuth 2.0 for Native Apps) 정독 + `raw/official-docs/security-oauth2-pkce-rfc-8252.md` 와 cross-check, 모바일 SDK 에서 BFF session cookie 핸들링 동작 확인 | `needs-confirmation` |
|
||||
| Curity 의 "OAuth Agent" 명명이 다른 vendor (Auth0, IdentityServer, Spring Authorization Server) 의 BFF 구현에도 1:1 대응되는지 | `CURITY-BFF-C3` 의 "OAuth Agent" 는 vendor-specific 명명 | 각 vendor 의 BFF docs 정독 — Spring Security `oauth2Login` 의 `OAuth2AuthorizedClient` 가 동등 역할인지 확인 | `planned` |
|
||||
| 결정 기준 매트릭스 (XSS 민감도 / 모바일 / stateless / 운영 / revocation / OAuth 2.1 권고 / multi-microservice) 의 각 셀이 본 sources 중 어느 인용으로 직접 뒷받침되는지 | 본 branch 본문 매트릭스는 종합 판단 — 셀별 source mapping 부재 | 각 셀마다 supporting claim 명시 또는 UNSUPPORTED 표시로 분해 | `planned` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 이슈 1: P1A(Edge ForwardAuth)와 BFF의 차이를 한 문장으로 설명하기 까다로움.
|
||||
- 원인: 둘 다 "토큰을 브라우저에서 분리"하지만 분리 주체와 위치가 다름
|
||||
- 시도: (문서 정리)
|
||||
- 해결: P1A = 별도 reverse proxy가 인증 / BFF = application backend 자체가 OAuth client — `documented-only`
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/company-tech-blogs/curity-bff-pattern-spa]]
|
||||
- [[raw/official-docs/keycloak-securing-apps-overview-official]]
|
||||
- [[raw/official-docs/oauth-v2-1-draft-ietf]]
|
||||
- [[raw/official-docs/owasp-html5-storage-xss-spa]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
> 본 sub-sub-branch 는 leaf — 자식 자료 없음. Phase 3 P3A 실 구현 또는 외부 산출물 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 오류 기록 (이 sub-sub-branch 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 documented-only 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — Phase 3 실 구현 단계에 누적)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 머지/종료 시점에 채움. `/ingest`가 이 섹션을 기준으로 wiki/projects/에 추출.
|
||||
|
||||
- PR 링크: (미구현 — 문서까지만)
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: 없음 (P2A는 `documented-only` 범위)
|
||||
- **wiki 추출 대상**: 현 단계 없음. 6 패턴 + BFF 매트릭스 완성 후 `wiki/concepts/bff-vs-spa-direct.md` 합성 후보.
|
||||
- **추출하지 않을 항목**: BFF 자체 구현 없음. SPA Direct도 P2A 구현 없음. `documented-only` 유지.
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
---
|
||||
title: branch / feature-keycloak-docker-compose-stack (docker-compose 환경 구성 — keycloak + postgres + spring + nginx)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-PATTERNS-OVERVIEW-001
|
||||
kind: project-work-item
|
||||
project: keycloak-patterns-overview
|
||||
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-001
|
||||
inherits: [DEC-KEYCLOAK-PATTERNS-OVERVIEW-DEPLOYMENT-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: []
|
||||
contract_packet: 1
|
||||
branch: feature-keycloak-docker-compose-stack
|
||||
parent_branch:
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, p3a, implementation, docker-compose, single-host, infra]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: c8ba20c0738c66a511f3acc218959a05d2b4ffef6616b2dfc528cd1f49915348
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-docker-compose-stack (docker-compose 환경 구성)
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/project-notes/keycloak-patterns-overview]] Work Item의 직접 자식 branch.
|
||||
> **P3A는 실 구현 대상**. 본 sub-sub는 학습 + 작업 plan 기록 — 실 구현은 별도 git repo `/home/donghyeon/workspace/keycloak-patterns/`.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/project-notes/keycloak-patterns-overview]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: Keycloak·PostgreSQL·nginx·Spring 4 containers가 healthy이고 admin console에 접속된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-DEPLOYMENT-001@1` | AP1~AP4 E2E 구현 topology는 single-EC2 docker-compose다 | Keycloak·PostgreSQL·nginx·Spring의 local single-host topology에 적용한다 | [[raw/project-notes/keycloak-patterns-overview]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
| D1 | 학습 환경에서는 Keycloak start-dev를 사용한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
| D2 | Keycloak database로 PostgreSQL을 사용한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
| D3 | healthcheck 기반 startup dependency를 사용한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
| D4 | realm JSON auto-import를 사용한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
| D5 | 학습 topology hostname을 localhost로 고정한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
| D6 | local port mapping과 admin secret 분리를 고정한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
| D7 | bridge retrieval profile은 owner 결정을 소비한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
없음.
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
Keycloak (PostgreSQL realm 저장) + Spring Boot + nginx (vanilla JS SPA static) **단일 host docker-compose 환경**을 구성한다. 학습 친화성의 핵심 지표는 **환경 reset 1줄** (`docker compose down -v && docker compose up -d`).
|
||||
|
||||
면접 질문: "OIDC 학습 환경을 어떻게 구성했나요?"
|
||||
→ "단일 EC2(또는 로컬) docker-compose 한 파일로 keycloak / postgres / spring boot / nginx 네 서비스를 띄웠습니다. volume 두 개(keycloak data, postgres data)를 정의해 realm export JSON이 자동 import되도록 했고, healthcheck로 backend가 keycloak ready 이후에만 기동하도록 `depends_on: condition: service_healthy`를 걸었습니다."
|
||||
|
||||
- 이슈:
|
||||
- PR: (별도 keycloak-patterns repo)
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- `docker-compose.yml` 작성 (서비스 4개)
|
||||
- volume 정의 (keycloak data, postgres data)
|
||||
- 단일 network
|
||||
- port 매핑: `8080` keycloak / `8081` spring boot / `80` nginx
|
||||
- `.env` 파일로 `KEYCLOAK_ADMIN` / `KEYCLOAK_ADMIN_PASSWORD` / `POSTGRES_PASSWORD` 분리
|
||||
- `depends_on` + healthcheck
|
||||
- 로컬 실행 명령어 문서화
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- Keycloak realm/client 설정 (→ [[raw/branch-notes/feature-keycloak-realm-client-export]])
|
||||
- Spring Boot 코드 (→ [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]])
|
||||
- SPA 코드 (→ [[raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce]])
|
||||
- HTTPS / Caddy (학습 환경)
|
||||
- prod 배포 / EC2 IaC
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
- [[raw/official-docs/keycloak-server-containers-docker]] — Keycloak Docker 공식 (KC_* 환경 변수)
|
||||
- [[raw/official-docs/keycloak-getting-started-docker]] — Docker quickstart
|
||||
- [[raw/official-docs/keycloak-health-checks]] — health endpoint 경로(`/health`, `/health/ready`, `/health/live`, `/health/started`), management port `9000`, `KC_HEALTH_ENABLED`(기본값 `false`) 활성화 요건의 공식 근거 (D3)
|
||||
- [[raw/official-docs/keycloak-configuring-database]] — Keycloak 공식 "Configuring the database" (`/server/db`) — `KC_DB=postgres` vendor 값, `KC_DB_URL`/`KC_DB_USERNAME`/`KC_DB_PASSWORD` JDBC 연결 환경변수 정확한 이름·형식. D2 (PostgreSQL 사용) 의 verbatim 근거 — `KC-DB-C1`~`KC-DB-C5`
|
||||
- [[raw/official-docs/keycloak-import-export-realms]] — Keycloak 공식 Import/Export 가이드 (`--import-realm` 옵션, 컨테이너 import 경로 `/opt/keycloak/data/import`, 기존 realm 존재 시 skip 동작 — D4 근거)
|
||||
- [[raw/official-docs/docker-compose-depends-on-healthcheck]] — Docker Compose 공식 (`depends_on` long syntax `condition: service_healthy` + `healthcheck` 필드 문법, D3 근거)
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급. **현재 모두 `planned`** — 실 구현 후 별도 작업에서 `actually-implemented`/`locally-verified`로 승급.
|
||||
|
||||
- [ ] `docker-compose.yml` 작성 — 등급: `planned`
|
||||
- [ ] 서비스 `keycloak` 정의 (`quay.io/keycloak/keycloak:26.x`, `start-dev`, env: `KC_DB=postgres`, `KC_DB_URL`, `KC_HOSTNAME=localhost`, `KC_HTTP_ENABLED=true`, `KC_BOOTSTRAP_ADMIN_USERNAME`, `KC_BOOTSTRAP_ADMIN_PASSWORD`) — 등급: `planned`
|
||||
- [ ] 서비스 `postgres` 정의 (`postgres:16`, env: `POSTGRES_DB=keycloak`, `POSTGRES_USER`, `POSTGRES_PASSWORD`) — 등급: `planned`
|
||||
- [ ] 서비스 `app` 정의 (Spring Boot, 빌드는 별도 Dockerfile, port `8081:8081`) — 등급: `planned`
|
||||
- [ ] 서비스 `nginx` 정의 (`nginx:alpine`, volume mount: SPA `dist/` → `/usr/share/nginx/html`, port `80:80`) — 등급: `planned`
|
||||
- [ ] volume 정의 (`keycloak_data`, `postgres_data`) — 등급: `planned`
|
||||
- [ ] 단일 network 정의 (`keycloak-net`) — 등급: `planned`
|
||||
- [ ] port 매핑: `8080:8080` (keycloak), `8081:8081` (app), `80:80` (nginx) — 등급: `planned`
|
||||
- [ ] `.env` 파일 작성 + `.gitignore`에 추가 (KEYCLOAK_ADMIN secret 노출 방지) — 등급: `planned`
|
||||
- [ ] `depends_on` healthcheck: postgres ready → keycloak 기동 / keycloak ready → app 기동 (`condition: service_healthy`) — 등급: `planned`
|
||||
- [ ] keycloak healthcheck (`/health/ready` 엔드포인트, `start-dev`에서 활성화) — 등급: `planned`
|
||||
- [ ] postgres healthcheck (`pg_isready`) — 등급: `planned`
|
||||
- [ ] realm export JSON auto-import volume (`./realm-export.json:/opt/keycloak/data/import/realm-export.json`) + `--import-realm` 옵션 — 등급: `planned`
|
||||
- [ ] 로컬 실행 명령어 문서화 (`docker compose up -d` / `docker compose logs -f keycloak` / `docker compose down -v`) — 등급: `planned`
|
||||
- [ ] README에 환경 reset 1줄 명령어 명시 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- Keycloak 26.x 기준 `KC_BOOTSTRAP_ADMIN_USERNAME`/`KC_BOOTSTRAP_ADMIN_PASSWORD`가 admin 부트스트랩에 사용됨 (구버전 `KEYCLOAK_ADMIN`은 deprecated).
|
||||
- `start-dev`는 학습 전용. `start --optimized`는 prod 모드 (build 단계 분리 필요).
|
||||
- `KC_HOSTNAME=localhost` 강제는 P3A 본질 — [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] 함정 시연용.
|
||||
- nginx는 단순 static 파일 서빙. SPA fallback (`try_files $uri /index.html`) 추가 검토 (history mode 사용 시).
|
||||
- `depends_on: condition: service_healthy`는 Compose v3 spec에서 사용 가능.
|
||||
- **(2026-07-16 자동조사)** `KC_HEALTH_ENABLED` 기본값은 `false` (`raw/official-docs/keycloak-health-checks.md#KC-HEALTH-C3`) — 명시적으로 켜지 않으면 `/health/ready` 가 노출되지 않아 healthcheck 가 영구 실패한다. health endpoint 는 main HTTP 포트가 아니라 **management port 9000** (`KC-HEALTH-C1`). 공식 컨테이너 이미지엔 `curl` 이 없어(`KC-HEALTH-C4`) healthcheck.test 는 bash `/dev/tcp` 패턴을 써야 한다.
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- 2026-05-25: **Keycloak 26.x + start-dev 사용.** 이유: 학습 환경, optimized 빌드 단계 회피.
|
||||
- 2026-05-25: **PostgreSQL 사용** (Keycloak 기본 H2 대신). 이유: realm 데이터 영속 + prod-like 환경 학습.
|
||||
- 2026-05-25: **healthcheck로 의존성 강제.** 이유: app의 첫 token 검증 네트워크 호출 전에 Keycloak readiness를 보장한다.
|
||||
- 2026-05-25: **realm JSON auto-import 채택.** 이유: 환경 reset 후에도 realm 설정 즉시 복원 — 학습 반복 비용 최소화.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> docker-compose 환경 구성 결정. **D2/D3/D4 의 `UNSUPPORTED_DECISION` 라벨은 2026-07-16 `/branch-spec` 자동조사(§5)로 모두 해소됨**: D2(PostgreSQL) → [[raw/official-docs/keycloak-configuring-database]] `KC-DB-C1`~`C5` (`official-vendor-doc`); D3(healthcheck depends_on) → [[raw/official-docs/docker-compose-depends-on-healthcheck]] `COMPOSE-DEP-*` (`official-standard`, Compose 문법) + [[raw/official-docs/keycloak-health-checks]] `KC-HEALTH-*` (`official-vendor-doc`, health endpoint/port/enable 요건); D4(realm auto-import) → [[raw/official-docs/keycloak-import-export-realms]] `KC-IMPORT-C1`~`C4` (`official-vendor-doc`).
|
||||
> `선택 조건` 열(R2)은 "이 조건이면 이 결정, 다른 조건이면 어떤 대안" — 근거 claim 으로 대안까지 명시.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | Keycloak 26.x + `start-dev` 사용 (학습 환경) | **학습/로컬/데모 환경일 때 이 결정.** prod 진입 시 → 대안 `start` (after `build`, optimized image) 로 전환 (`KC-CONTAINER-C3` 이 dev mode 의 prod 사용을 strictly avoid 하라 경고, `KC-CONTAINER-C4` 가 optimized build 근거). | `raw/official-docs/keycloak-server-containers-docker.md#KC-CONTAINER-C2`, `raw/official-docs/keycloak-server-containers-docker.md#KC-CONTAINER-C3`, `raw/official-docs/keycloak-getting-started-docker.md#KC-GSD-C1`, `raw/official-docs/keycloak-getting-started-docker.md#KC-GSD-C2` | `official-vendor-doc` | `KC-CONTAINER-C3` 은 production 에서 `start-dev` strictly avoided 라고 경고 — 본 결정은 학습 한정. 실수로 prod 노출 시 보안 사고 |
|
||||
| D2 | PostgreSQL 사용 (Keycloak 기본 `dev-file` 대신) | **realm 데이터 영속 + prod-like 환경 학습이 목표일 때 이 결정.** 순수 throwaway 데모(영속 불필요)면 → 대안 기본 `dev-file` (설정 0, `KC-DB-C1`). 조직이 다른 RDBMS 로 표준화돼 있으면 → 대안 mariadb/mysql/mssql/oracle/tidb (`KC-DB-C2` 동등 지원). | `raw/official-docs/keycloak-configuring-database.md#KC-DB-C1`, `raw/official-docs/keycloak-configuring-database.md#KC-DB-C2`, `raw/official-docs/keycloak-configuring-database.md#KC-DB-C3`, `raw/official-docs/keycloak-configuring-database.md#KC-DB-C4`, `raw/official-docs/keycloak-configuring-database.md#KC-DB-C5` | `official-vendor-doc` | `KC-DB-C2` 는 postgres 가 *지원됨*을 증명할 뿐 *권장됨*은 증명 안 함 (mariadb/mysql/mssql/oracle/tidb 도 동등 지원) — PostgreSQL 선택 자체는 branch 의 "prod-like 환경 학습" 이유에 의한 자체 결정. 페이지가 rolling docs 라 Keycloak 26.x 특정 버전에 pin 된 확인은 아님 |
|
||||
| D3 | healthcheck 로 의존성 강제 (`depends_on: condition: service_healthy`) | **app 의 startup discovery 또는 첫 JWT 검증 네트워크 호출 전에 Keycloak readiness 를 보장해야 할 때 이 결정.** 서비스 간 readiness 의존이 없으면 → 대안 short syntax (`depends_on: [x]`, 순서만·healthy 대기 안 함 `COMPOSE-DEP-C4`) 또는 `depends_on` 생략. | `raw/official-docs/docker-compose-depends-on-healthcheck.md#COMPOSE-DEP-C1`, `raw/official-docs/docker-compose-depends-on-healthcheck.md#COMPOSE-DEP-C2`, `raw/official-docs/docker-compose-depends-on-healthcheck.md#COMPOSE-DEP-C5`, `raw/official-docs/docker-compose-depends-on-healthcheck.md#COMPOSE-DEP-C6`, `raw/official-docs/keycloak-health-checks.md#KC-HEALTH-C1`, `raw/official-docs/keycloak-health-checks.md#KC-HEALTH-C2`, `raw/official-docs/keycloak-health-checks.md#KC-HEALTH-C3`, `raw/official-docs/keycloak-health-checks.md#KC-HEALTH-C4` | `official-standard + official-vendor-doc` | Compose 는 "시작 순서" 만 보증(`COMPOSE-DEP-C5`) — health probe 자체의 정확성은 Keycloak 측 근거로 확보(`KC-HEALTH-*`). 남은 위험: `KC_HEALTH_ENABLED` 를 `start-dev` 가 **runtime env** 로 받는지 vs **build-time 옵션**인지는 `KC-HEALTH-C3` 로 확정 안 됨 (아래 Claims To Verify). 미설정 시 기본 `false` → healthcheck 영구 실패(§엣지·실패·의존) |
|
||||
| D4 | realm JSON auto-import 채택 (`--import-realm` + volume mount) | **환경 reset 반복 + realm 설정 즉시 복원이 목표일 때 이 결정** (`down -v` 후 재기동 시 재import). 1회성 수동 설정이면 → 대안 Admin UI 수동 생성. 기존 realm 을 강제로 덮어써야 하면 → 대안 offline `import` 명령 (`--override` 기본 true, `KC-IMPORT-C4`; auto-import 는 skip `KC-IMPORT-C3`). | `raw/official-docs/keycloak-import-export-realms.md#KC-IMPORT-C1`, `raw/official-docs/keycloak-import-export-realms.md#KC-IMPORT-C2`, `raw/official-docs/keycloak-import-export-realms.md#KC-IMPORT-C3`, `raw/official-docs/keycloak-import-export-realms.md#KC-IMPORT-C4` | `official-vendor-doc` | `KC-IMPORT-C2` 는 페이지 버전 셀렉터(Nightly/26.7.0)만 노출 — 특정 26.x patch 에 pin 된 확인은 아님. `KC-IMPORT-C1` 은 컨테이너 이미지의 entrypoint/CMD 가 `--import-realm` 을 실제로 어떻게 전달받는지까지는 증명 안 함 (컨테이너 entrypoint 세부는 별도 확인 필요, 아래 Claims To Verify) |
|
||||
| D5 | `KC_HOSTNAME=localhost` 강제 (P3A 본질, iss claim 함정 시연용) | **iss claim mismatch 함정을 의도적으로 시연·학습할 때 이 결정** (parent D3 와 결합). prod 진입 시 → 대안 `hostname-strict=true` + 실제 도메인 (`KC-HOST-C5`). | `raw/official-docs/keycloak-hostname-configuration.md#KC-HOST-C2`, `raw/official-docs/keycloak-hostname-configuration.md#KC-HOST-C3`, `raw/official-docs/keycloak-server-containers-docker.md#KC-CONTAINER-C1` | `official-vendor-doc` | `KC-HOST-C5` 는 production 에서 hostname-strict true 권고 — 학습 환경 한정으로 충분. 실제 mismatch 시연 동작은 sibling `feature-keycloak-iss-claim-hostname-mismatch` 에서 검증 |
|
||||
| D6 | port 매핑: keycloak `8080:8080`, app `8081:8081`, nginx `80:80` + .env 로 admin secret 분리 | **단일 host 학습 환경에서 세 서비스에 브라우저가 직접 접근해야 할 때 이 결정** (전 인터페이스 bind). 외부 노출/prod 면 → 대안 loopback bind `127.0.0.1:8080:8080` (`KC-GSD-C1`) + reverse proxy 뒤 배치. | `raw/official-docs/keycloak-getting-started-docker.md#KC-GSD-C1` (quickstart 의 8080 노출 패턴) | `official-vendor-doc` | `KC-GSD-C1` 은 `127.0.0.1:8080:8080` (loopback bind) 명시 — 본 결정은 `8080:8080` (모든 인터페이스) 사용. 학습 환경 외 EC2 외부 노출 시 admin 인증 우회 위험 |
|
||||
| D7 | **DELEGATED** — [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] D6 — bridge retrieval profile | owner가 선택한 profile을 Compose `app` service에 배치·wiring할 때만 본 task가 적용된다. profile 값이나 대안 선택은 owner에서 변경한다. | owner 참조 | `delegated` | Compose wiring의 runtime 도달성은 owner의 401→200 E2E 전까지 `needs-confirmation` |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 본 §는 위 Decision 들이 *어디에 어떻게* 구현되는가의 사전 명세 (다음 구현자가 되묻지 않고 `docker-compose.yml` 을 작성할 수준). in-scope 항목만. 각 sub-section 은 Decision ID + Supporting Claim ID 를 Trace 로 명시하고, 근거가 detail 을 규정하지 않는 임의 결정은 `UNSUPPORTED_IMPL_DECISION` 라벨 + trade-off 한 줄로 남긴다.
|
||||
> **범위 경계**: `app`/`nginx` 서비스의 *service 정의*(이미지·포트·마운트·depends_on)는 본 branch 의 compose 파일 in-scope 이지만, 그 *빌드 산출물*(Spring jar/Dockerfile, SPA `dist/`, realm JSON)은 sibling branch 소유 → §엣지·실패·의존 의 "다른 계약 의존" 참조.
|
||||
|
||||
### 1. docker-compose 서비스 정의 (4 services)
|
||||
|
||||
> **Trace**: D1 (keycloak `start-dev`, `KC-CONTAINER-C2`) · D2 (postgres 연결 env, `KC-DB-C2`/`C3`/`C4`/`C5`) · D5 (`KC_HOSTNAME`, `KC-HOST-C2`).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**:
|
||||
> - postgres 이미지 태그 `postgres:16` — Keycloak 문서는 vendor(`postgres`)만 명시하고 특정 major 를 권고 안 함(`KC-DB-C2`). trade-off: 16 = 현시점 안정 major, 단 Keycloak 26.x DB 지원 매트릭스 재확인 필요.
|
||||
> - `KC_DB_URL` 의 host = compose service 명 `postgres`. `KC-DB-C4` 기본형은 `jdbc:postgresql://localhost/keycloak`, `KC-DB-C3` 예시는 `db-url-host=keycloak-postgres` — 값이 문서마다 달라 컨테이너 내부 DNS(=service 명)에 맞춰 임의 결정. trade-off: postgres service 이름을 바꾸면 URL 도 바뀜.
|
||||
> - `nginx:alpine` 태그 — 경량 목적 임의 선택. trade-off: 정적 서빙이라 musl libc 이슈 가능성 낮음.
|
||||
|
||||
| 서비스 | 이미지 | command / 핵심 env | port | Trace |
|
||||
|---|---|---|---|---|
|
||||
| `keycloak` | `quay.io/keycloak/keycloak:26.x` | `start-dev --import-realm`; `KC_DB=postgres`, `KC_DB_URL=jdbc:postgresql://postgres:5432/keycloak`, `KC_DB_USERNAME`, `KC_DB_PASSWORD`, `KC_HOSTNAME=localhost`, `KC_HTTP_ENABLED=true`, `KC_HEALTH_ENABLED=true`, `KC_BOOTSTRAP_ADMIN_USERNAME`, `KC_BOOTSTRAP_ADMIN_PASSWORD` | `8080:8080` | D1·D2·D4·D5·D6 |
|
||||
| `postgres` | `postgres:16` | `POSTGRES_DB=keycloak`, `POSTGRES_USER`, `POSTGRES_PASSWORD` | (내부만) | D2 |
|
||||
| `app` | 별도 Dockerfile 빌드 (sibling) | [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] D6가 선택한 bridge retrieval profile의 Compose 배치·host wiring만 수행 | `8081:8081` | D6·D7 (delegated owner D6) |
|
||||
| `nginx` | `nginx:alpine` | SPA `dist/`(sibling) → `/usr/share/nginx/html` mount | `80:80` | D6 |
|
||||
|
||||
### 2. 네트워크 · 포트 · 볼륨 토폴로지
|
||||
|
||||
> **Trace**: D6 (port 매핑, `KC-GSD-C1`) · 범위 §In scope (단일 network, volume 2개) · `KC-HEALTH-C1` (health/management port 9000 은 내부 전용).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**:
|
||||
> - network 이름 `keycloak-net`, volume 이름 `keycloak_data`/`postgres_data` — Docker 문서 미규정, 가독성 위주 임의 명명. trade-off: 충돌 시 rename 만 하면 됨.
|
||||
> - management/health port `9000` 을 host 로 매핑하지 않음 — healthcheck 는 컨테이너 내부 `/dev/tcp/localhost/9000` 로 수행(`KC-HEALTH-C4`)하므로 외부 노출 불필요. trade-off: 외부에서 `/health/ready` 를 직접 디버깅하려면 `9000:9000` 을 임시 추가.
|
||||
|
||||
- network: `keycloak-net` (단일 bridge, 4개 서비스 동일 network)
|
||||
- volumes: `keycloak_data`, `postgres_data` (postgres data 영속 → realm 유지; `down -v` 시 삭제되어 reset)
|
||||
- ports (host:container): keycloak `8080:8080`, app `8081:8081`, nginx `80:80`
|
||||
|
||||
### 3. 의존성 순서 + healthcheck
|
||||
|
||||
> **Trace**: D3 (`COMPOSE-DEP-C1`/`C2`/`C5`/`C6` — depends_on long syntax + healthcheck 필드; `KC-HEALTH-C1`~`C4` — endpoint/port/enable/커맨드).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**:
|
||||
> - healthcheck `interval`/`timeout`/`retries`/`start_period` 수치 — `COMPOSE-DEP-C6` 은 필드 *존재*만 보증하고 값은 미권고. trade-off: keycloak 초기 기동이 느려 `start_period` 를 크게(예 40~60s) 잡음 — 임의값, 실측 후 조정.
|
||||
> - keycloak healthcheck 를 `/dev/tcp` in-container probe 로 둘지 vs `depends_on` 만 믿을지 — `KC-HEALTH-C4` 의 공식 curl-free Containerfile 패턴 채택. trade-off: bash `/dev/tcp` 는 keycloak 이미지에 내장된 bash 필요(존재함).
|
||||
|
||||
| 서비스 | healthcheck.test | depends_on (condition) | Trace |
|
||||
|---|---|---|---|
|
||||
| `postgres` | `pg_isready -U $POSTGRES_USER` | — | COMPOSE-DEP-C6 |
|
||||
| `keycloak` | bash `/dev/tcp` → `HEAD /health/ready` on `:9000` (curl 없음 `KC-HEALTH-C4`); 전제 `KC_HEALTH_ENABLED=true` `KC-HEALTH-C3` | `postgres: {condition: service_healthy}` | COMPOSE-DEP-C2, KC-HEALTH-C1/C2/C3/C4 |
|
||||
| `app` | (Spring actuator `/actuator/health` — sibling 소유) | `keycloak: {condition: service_healthy}` — owner profile의 첫 token 검증 네트워크 호출 전 readiness 보장 | COMPOSE-DEP-C2/C5, D7 |
|
||||
| `nginx` | (선택) | `app: {condition: service_started}` (static only, 강 의존 아님) | COMPOSE-DEP-C4 |
|
||||
|
||||
> **keycloak healthcheck 정확형** (`KC-HEALTH-C4` verbatim 커맨드 인라인 — depth-audit finding #1): compose 의 `test:` 는 반드시 **bash 형태**로 명시한다. 기본 `CMD-SHELL` 은 `/bin/sh`(dash)라 `/dev/tcp` redirect 를 지원하지 않아 실패하므로 `bash -c` 를 강제:
|
||||
>
|
||||
> ```yaml
|
||||
> healthcheck:
|
||||
> test: ["CMD", "bash", "-c", "{ printf 'HEAD /health/ready HTTP/1.0\r\n\r\n' >&0; grep 'HTTP/1.0 200'; } 0<>/dev/tcp/localhost/9000"]
|
||||
> interval: 10s # UNSUPPORTED_IMPL_DECISION — COMPOSE-DEP-C6 필드만 보증, 값 임의
|
||||
> timeout: 5s
|
||||
> retries: 12
|
||||
> start_period: 60s # keycloak 초기 기동 느림 → 크게
|
||||
> ```
|
||||
|
||||
### 4. Secret 분리 (.env)
|
||||
|
||||
> **Trace**: D6 (.env 로 admin secret 분리) · 범위 §In scope.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**:
|
||||
> - `.env` 키 이름 `KEYCLOAK_ADMIN`/`KEYCLOAK_ADMIN_PASSWORD` (범위 §In scope 표기) vs 컨테이너 env `KC_BOOTSTRAP_ADMIN_USERNAME`/`KC_BOOTSTRAP_ADMIN_PASSWORD` (26.x, `KEYCLOAK_ADMIN` 자체는 deprecated — 진행중 메모) — 매핑을 compose `environment:` 에서 `KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN}` 형태로 연결. trade-off: 레거시 키 이름을 그대로 쓰면 혼란 → compose 에 주석 필요. (권고: `.env` 키도 `KC_BOOTSTRAP_ADMIN_*` 로 통일 고려.)
|
||||
|
||||
- `.env`: `KEYCLOAK_ADMIN`, `KEYCLOAK_ADMIN_PASSWORD`, `POSTGRES_PASSWORD` (+ `KC_DB_PASSWORD` 는 `POSTGRES_PASSWORD` 공유 또는 별도)
|
||||
- `.gitignore` 에 `.env` 추가 (secret 커밋 방지)
|
||||
- compose `environment:` 에서 `${VAR}` 치환 + `KEYCLOAK_ADMIN → KC_BOOTSTRAP_ADMIN_USERNAME` 매핑
|
||||
|
||||
### 5. Realm auto-import
|
||||
|
||||
> **Trace**: D4 (`KC-IMPORT-C1` `--import-realm` startup import · `KC-IMPORT-C2` 컨테이너 경로 `/opt/keycloak/data/import`, `.json` 만 · `KC-IMPORT-C3` 기존 realm skip).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**:
|
||||
> - mount 를 **파일**(`./realm-export.json:/opt/keycloak/data/import/realm-export.json`) vs **디렉토리**(`./import:/opt/keycloak/data/import`)로 할지 — `KC-IMPORT-C2` 는 서버가 import *디렉토리*를 스캔(`.json` only, sub-dir 무시)한다고 명시하므로 디렉토리 mount 가 더 안전. 범위 §In scope 는 파일 단위 mount 표기. trade-off: 파일 단위도 동작하나 realm 여러 개로 확장 시 디렉토리 mount 권장 → **정합 권고**: 디렉토리 mount 로 조정 검토.
|
||||
|
||||
- keycloak command: `start-dev --import-realm`
|
||||
- volume mount: `./realm-export.json:/opt/keycloak/data/import/realm-export.json:ro` (또는 위 정합 권고대로 디렉토리 mount)
|
||||
- 재import 동작(`KC-IMPORT-C3`): 기존 realm 존재 시 skip → realm JSON 수정 반영하려면 `down -v`(postgres volume 삭제) 후 재기동, 또는 offline `import --override`
|
||||
- realm JSON 산출물(`realm-export.json`)은 sibling `feature-keycloak-realm-client-export` 소유 (§엣지·실패·의존)
|
||||
|
||||
### 6. Owner retrieval profile의 Compose wiring (D7 delegated)
|
||||
|
||||
> **Trace**: [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] D6 — bridge retrieval profile.
|
||||
>
|
||||
> profile 값·선택·fallback은 owner만 변경한다. 본 branch는 owner가 선택한 profile을 Compose `app` service에 배치하고, 그 profile이 요구하는 host wiring을 연결하는 책임만 가진다.
|
||||
|
||||
- Compose `app` service에 owner-selected profile과 필수 host mapping을 배치한다.
|
||||
- acceptance: `docker compose config`가 해당 wiring을 해석하고 app container가 owner의 retrieval endpoint에 도달한다. profile 값은 이 문서에 복사하지 않는다.
|
||||
- runtime 도달성 status: `needs-confirmation`.
|
||||
|
||||
### 7. 로컬 실행 · 환경 reset 명령
|
||||
|
||||
> **Trace**: 목표 §WHY (환경 reset 1줄 = 학습 친화성 핵심 지표). 표준 compose 명령이라 UNSUPPORTED 없음.
|
||||
|
||||
- 기동: `docker compose up -d`
|
||||
- 로그: `docker compose logs -f keycloak`
|
||||
- **환경 reset 1줄**: `docker compose down -v && docker compose up -d` (volume 삭제 → realm 재import)
|
||||
- README 에 위 reset 1줄 명시
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> 정상 경로 외에 구현 중 부딪힐 실패/엣지, 그리고 다른 branch 계약 의존을 미리 열거 (R4).
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **health-disabled 함정 (신규 발견, `KC-HEALTH-C3`)**: `KC_HEALTH_ENABLED` 기본 `false` → 설정 누락 시 `/health/ready` 미노출 → keycloak healthcheck 영구 unhealthy → `depends_on: service_healthy` 로 `app` 이 영구 대기(교착). 기대 동작: keycloak env 에 `KC_HEALTH_ENABLED=true` 명시.
|
||||
- **curl 부재 (`KC-HEALTH-C4`)**: healthcheck.test 에 `curl`/`wget` 사용 시 "not found" 로 항상 실패 → bash `/dev/tcp/localhost/9000` raw HTTP 패턴 필요.
|
||||
- **postgres not-ready**: keycloak 이 postgres healthy 전에 기동하면 DB 연결 실패로 crash-loop → `depends_on: postgres {condition: service_healthy}` + `pg_isready`.
|
||||
- **realm import 재실행 idempotency (`KC-IMPORT-C3`)**: 기존 realm skip → realm JSON 수정해도 `down -v` 없이 재기동하면 **반영 안 됨**. 기대: `down -v` 후 재기동 또는 offline `import --override`.
|
||||
- **volume mount permission**: EC2 ubuntu(UID 1000) vs 컨테이너 UID → data volume ownership 충돌로 `permission denied` 가능 (→ Claims To Verify).
|
||||
- **nginx SPA history-mode fallback**: deep-link(`/some/route`) 직접 GET 시 `try_files $uri /index.html` 없으면 404.
|
||||
- **iss mismatch (의도적 함정)**: browser 와 컨테이너의 address 관점 차이로 JWT `iss` 검증이 실패할 수 있다. [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] D6 — bridge retrieval profile이 해결 owner이며, 본 branch는 그 profile의 Compose wiring만 수행한다.
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] D6 — bridge retrieval profile. 본 branch는 owner-selected profile의 Compose 배치·host wiring만 수행한다.
|
||||
- [[raw/branch-notes/feature-keycloak-realm-client-export]] 에 의존 — auto-import 대상 `realm-export.json`(realm+client+테스트 사용자)의 owner. realm 구조 변경 시 mount 파일 변경.
|
||||
- [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] 에 의존 — `app` service 가 실행하는 Spring Boot 이미지/Dockerfile 의 owner (build context 계약).
|
||||
- [[raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce]] 에 의존 — `nginx` service 가 서빙하는 SPA `dist/` 산출물의 owner.
|
||||
- [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] D6 — iss mismatch 시연·검증과 profile 값의 owner. 본 compose 의 `KC_HOSTNAME`/network 결정은 그 시연의 배치 전제다.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> Docker Compose 환경 구성은 실제 `docker compose up -d` 후에만 검증 가능. 근거 확보된 claim 은 status 를 `documented-only`(문법·명세는 공식 확인, 로컬 실행만 남음)로 표기.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| `KC_BOOTSTRAP_ADMIN_USERNAME` / `KC_BOOTSTRAP_ADMIN_PASSWORD` 가 Keycloak 26.x 의 admin 부트스트랩 환경변수 (구버전 `KEYCLOAK_ADMIN` 대체) | `KC-CONTAINER-C5` 는 정확한 환경변수 이름이 verbatim 부재로 `needs-confirmation` | `quay.io/keycloak/keycloak:26.x` 컨테이너 시작 후 admin 로그인 시도 + Keycloak release notes 확인 | `needs-confirmation` |
|
||||
| `KC_HEALTH_ENABLED` 를 `start-dev` 가 **runtime env** 로 받는지 (vs build-time 옵션), 켜면 `/health/ready` 가 management port `9000` 에서 노출되는지 | endpoint 경로·port·기본값(`false`)·활성화 플래그는 `KC-HEALTH-C1`~`C4` 로 확보됐으나, `start-dev` 가 이 옵션을 build-time 으로 요구하는지 runtime env 로 받는지의 구분은 `KC-HEALTH-C3` 로 확정 안 됨 (해당 raw 의 Usage Boundaries 에도 명시) | `docker compose up -d` 후 `docker compose exec keycloak bash -c '... /dev/tcp/localhost/9000'` 로 `/health/ready` 200 확인 + healthcheck 상태 `healthy` 확인 | `needs-confirmation` |
|
||||
| `depends_on: condition: service_healthy` 가 Compose spec 에서 사용 가능 | 2026-07-16 [[raw/official-docs/docker-compose-depends-on-healthcheck]] `COMPOSE-DEP-C1`/`C2`/`C5` 로 문법·의미 확인 완료 — 남은 불확실성은 로컬 `docker compose version` 이 이 문법을 지원하는 실제 버전인지만 | `docker compose config` 로 파싱 에러 없이 로드되는지 확인 (문법은 이미 공식 확인됨) | `documented-only` (문법 근거 확보, 로컬 실행 검증만 남음) |
|
||||
| `--import-realm` + `/opt/keycloak/data/import/` 경로가 Keycloak 26.x 컨테이너에서 동작 | 2026-07-16 [[raw/official-docs/keycloak-import-export-realms]] `KC-IMPORT-C1`/`C2` 로 옵션·컨테이너 경로·skip 동작 확인 — 남은 불확실성은 공식 이미지 entrypoint/CMD 가 `--import-realm` 을 실제로 전달하는지 + 로컬 실행 | `docker compose up -d` 후 Admin UI 에서 realm 자동 import 확인 + `docker compose logs keycloak` 에 import 로그 확인 | `documented-only` (옵션·경로 근거 확보, entrypoint 전달·로컬 실행 검증만 남음) |
|
||||
| volume mount permission (EC2 ubuntu user UID 1000 vs keycloak container UID 1000) 충돌 없이 동작 | OS / container UID 매핑은 공식 인용 범위 밖, 운영 환경 의존 | `docker compose up` 후 keycloak data volume 의 ownership 확인 + permission denied 에러 부재 확인 | `planned` |
|
||||
| nginx static 서빙에서 SPA history mode 사용 시 `try_files $uri /index.html` fallback 동작 | 본 sub-sub-branch 의 in scope 결정 - nginx 설정 자체는 raw source 인용 없음 | SPA 의 `/some/spa/route` 직접 GET 시 index.html 반환 확인 | `planned` |
|
||||
| [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] D6가 선택한 bridge retrieval profile의 Compose host wiring이 app container에서 동작 | profile 값·선택은 owner가 소유하고, Compose runtime reachability만 환경 의존 | `docker compose config`로 owner-required host wiring 확인 → app container에서 owner retrieval endpoint 도달 → owner의 401→200 E2E 확인 | `needs-confirmation` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- (구현 시작 후 추가) `depends_on healthy` 미사용 시 app 기동 직후 들어온 첫 인증 요청이 Keycloak readiness 전에 JWKS를 fetch하면 검증 실패 예상.
|
||||
- (구현 시작 후 추가) volume mount permission 이슈 (특히 EC2 ubuntu user vs container UID) 예상.
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/docker-compose-depends-on-healthcheck]]
|
||||
- [[raw/official-docs/keycloak-configuring-database]]
|
||||
- [[raw/official-docs/keycloak-getting-started-docker]]
|
||||
- [[raw/official-docs/keycloak-health-checks]]
|
||||
- [[raw/official-docs/keycloak-import-export-realms]]
|
||||
- [[raw/official-docs/keycloak-server-containers-docker]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
> 본 sub-sub-branch 는 leaf — 자식 자료 없음. Phase 3 P3A 실 구현 또는 외부 산출물 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 오류 기록 (이 sub-sub-branch 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 documented-only 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — Phase 3 실 구현 단계에 누적)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 실 구현(`/home/donghyeon/workspace/keycloak-patterns/`)에서 `docker compose up -d` 정상 기동 후 `planned` → `actually-implemented`/`locally-verified` 승급.
|
||||
|
||||
- PR 링크: (별도 keycloak-patterns repo)
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: 로컬 docker-compose / 단일 EC2 시뮬레이션
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목: (구현 후 채움)
|
||||
- `locally-verified` 항목: (구현 후 채움)
|
||||
- `prod-verified` 항목: (없음)
|
||||
- **추출하지 않을 항목**: 현재 전부 `planned`.
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
---
|
||||
title: branch / feature-keycloak-edge-forwardauth-google-federation (P1B Edge Forward Auth + Google federation)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-CHILD-8F3B8B4E
|
||||
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-edge-forwardauth-google-federation
|
||||
parent_branch: feature-keycloak-patterns
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, auth, oauth2, oidc]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 0d0cfce93fa2e7290518d60b46e5559d479877936fb26cc8dacbf2232cb0d8d3
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-edge-forwardauth-google-federation (P1B Edge Forward Auth + Google federation)
|
||||
|
||||
> Layer: `raw/branch-notes/` — root [[raw/branch-notes/feature-keycloak-patterns]]의 sub-branch.
|
||||
> 패턴 ID: **P1B** — Edge ForwardAuth (oauth2-proxy / Traefik) + Keycloak에 Google을 외부 IdP로 brokering.
|
||||
> 비교 대상: [[raw/branch-notes/feature-keycloak-edge-forwardauth-no-google]] (P1A: 같은 배치, Google 없음).
|
||||
> `status_label`: `in-progress`
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent branch (root)**: [[raw/branch-notes/feature-keycloak-patterns]]
|
||||
- **Parent project**: [[raw/project-notes/keycloak-patterns-overview]]
|
||||
- **Sibling sub-branches**:
|
||||
- [[raw/branch-notes/feature-keycloak-edge-forwardauth-no-google]] — P1A (Edge, no Google)
|
||||
- [[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]] — P2A
|
||||
- [[raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation]] — P2B
|
||||
- [[raw/branch-notes/feature-keycloak-single-ec2-no-google]] — P3A
|
||||
- [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]] — P3B
|
||||
|
||||
<!-- 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 변형이다 | Edge ForwardAuth에 Google federation을 결합한 cross-cutting 변형으로 분류한다 | [[raw/project-notes/keycloak-patterns-overview]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
| D1 | First Login Review Profile을 기본 off로 둔다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
| D2 | automatic email linking 대신 manual confirm을 사용한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
| D3 | environment별 Google OAuth client를 분리한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
| D4 | Google scope를 openid profile email로 제한한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
| D5 | persistent federation key policy는 child owner를 소비한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
없음.
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
## 묶음 (자식 sub-sub-branches)
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/company-tech-blogs/keycloak-google-login-codemancers]]
|
||||
- [[raw/official-docs/google-openid-connect-oidc]]
|
||||
- [[raw/official-docs/keycloak-first-login-flow]]
|
||||
- [[raw/official-docs/keycloak-google-idp-setup]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
- [[raw/branch-notes/feature-keycloak-idp-brokering-google-client]]
|
||||
- [[raw/branch-notes/feature-keycloak-first-broker-login-flow]]
|
||||
- [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]]
|
||||
- [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]]
|
||||
|
||||
> Sources는 하단 "외부 근거" 섹션. Errors/Interview/Lectures/Blog 는 현재 없음.
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
P1A에 외부 IdP(Google)가 붙으면 토큰 흐름이 어떻게 확장되는지를 명확히 한다.
|
||||
|
||||
**핵심 통찰:** **edge proxy 입장에서는 변화가 없다.** oauth2-proxy는 여전히 Keycloak 한 곳에만 redirect 하고, Keycloak이 발급한 Keycloak access token만 받는다. Google federation은 **Keycloak 내부에서 일어나는 외부 IdP brokering 흐름**이며, edge / backend 입장에서는 투명(transparent)하다.
|
||||
|
||||
면접 / 설계 시 자주 헷갈리는 지점:
|
||||
- "Google 로그인을 붙이면 backend가 Google ID token을 검증해야 하나?" → **아니다.** P1B backend trust는 [[raw/branch-notes/feature-keycloak-edge-forwardauth-no-google]] D3 — P1A와 같은 header-only 계약이며 Google token과 Keycloak token을 backend로 전달하지 않는다.
|
||||
- "edge proxy가 Google client secret을 알아야 하나?" → **아니다.** Google credential은 Keycloak이 보관·사용.
|
||||
- "추가되는 trust hop은 어디인가?" → **Google → Keycloak.** P1A 대비 추가된 신뢰 경계는 이 한 hop.
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- P1B 컴포넌트 다이어그램 (P1A 대비 추가 컴포넌트 표시)
|
||||
- P1A 대비 추가되는 토큰 교환 단계 (4–8) 명시
|
||||
- Google federation 시 추가되는 신뢰 경계와 보안 surface
|
||||
- First Login Flow 정책 결정 지점 (Review Profile / Account Linking)
|
||||
- P1A 대비 장단점 / 운영 비용 비교
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 실제 구현 (P1B는 문서까지만 — root의 implementation 대상은 P3A)
|
||||
- Google 외 IdP (GitHub / Facebook / Apple). Google만.
|
||||
- Keycloak Authentication Flow custom code (Java SPI). 설정 옵션 수준까지.
|
||||
- prod 환경 Google API rate limit / quota 분석.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 본 sub-branch의 P1B (Edge + Google federation) 채택 근거. 상세 대안 비교는 §외부 근거 / 대안 조사 참조.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/keycloak-identity-brokering-overview-official]] | Keycloak Identity Broker 공식 — Google IdP brokering 패턴 채택 근거 |
|
||||
| [[raw/official-docs/keycloak-google-idp-setup]] | Keycloak Google IdP setup 절차 — 구성 단계 근거 |
|
||||
| [[raw/official-docs/google-openid-connect-oidc]] | Google OIDC 표준 (issuer, scopes, claims) — Google IdP 표준 동작 근거 |
|
||||
| [[raw/official-docs/keycloak-first-login-flow]] | First Broker Login Flow — Account Linking 정책 근거 |
|
||||
| [[raw/company-tech-blogs/keycloak-google-login-codemancers]] | Keycloak + Google 통합 실무 사례 (참고) |
|
||||
|
||||
## 컴포넌트 다이어그램
|
||||
|
||||
```
|
||||
Browser
|
||||
│
|
||||
▼
|
||||
Ingress (nginx / Traefik)
|
||||
│
|
||||
▼
|
||||
ForwardAuth (oauth2-proxy) ────► Keycloak (realm: app)
|
||||
│ (사용자가 "Sign in with Google" 클릭)
|
||||
▼
|
||||
Google OIDC
|
||||
(authorize / token / userinfo)
|
||||
│ (Google ID token + access token)
|
||||
▼
|
||||
Keycloak
|
||||
(First Login Flow:
|
||||
Google sub/email → Keycloak user
|
||||
매핑 또는 신규 생성)
|
||||
│ (Keycloak access token 발급)
|
||||
▼
|
||||
oauth2-proxy
|
||||
│ (proxy 세션 cookie 셋팅 + 헤더 주입)
|
||||
▼
|
||||
Ingress
|
||||
│
|
||||
▼
|
||||
Backend
|
||||
- edge가 주입한 trusted header만 신뢰
|
||||
- JWT/Google token은 보지 않음
|
||||
```
|
||||
|
||||
핵심 표시:
|
||||
- **edge proxy ↔ Keycloak 구간 = P1A와 동일.**
|
||||
- **Keycloak ↔ Google 구간 = P1B에서 새로 추가된 leg.**
|
||||
- **backend trust = [[raw/branch-notes/feature-keycloak-edge-forwardauth-no-google]] D3 — edge-injected header only.** Google federation은 이 경계를 바꾸지 않는다.
|
||||
|
||||
## 토큰 교환 sequence (P1A 대비 추가 단계 포함)
|
||||
|
||||
P1A 단계와 일치하는 부분은 그대로, Google federation 분기만 새 번호로 표기.
|
||||
|
||||
1. (P1A 1과 동일) 사용자 브라우저가 보호 리소스 GET → Ingress → oauth2-proxy.
|
||||
2. (P1A 2와 동일) oauth2-proxy: 세션 없음 → Keycloak `authorize` redirect.
|
||||
3. (P1A 3과 동일) Keycloak 로그인 페이지 표시.
|
||||
4. **(추가)** Keycloak 로그인 UI에 "Sign in with Google" 버튼 노출 (Identity Provider로 Google 등록 시 자동).
|
||||
5. **(추가)** 사용자 버튼 클릭 → Keycloak → Google `authorize` endpoint redirect (`https://accounts.google.com/o/oauth2/v2/auth`, scope=`openid profile email`).
|
||||
6. **(추가)** 사용자 Google 로그인 → Google → Keycloak broker callback (`/realms/<realm>/broker/google/endpoint`, `code` 전달).
|
||||
7. **(추가)** Keycloak → Google `/token` (`https://oauth2.googleapis.com/token`), Google ID token + access token 수신.
|
||||
8. **(추가)** Keycloak: Google ID token의 `sub`(영구 식별자) / `email` claim → **First Login Flow** 진입.
|
||||
- 기존 federated user 있음 → 그대로 매핑된 Keycloak user 사용.
|
||||
- 없고 email match로 기존 local user 있음 → Handle Existing Account 서브플로우 (자동 링크 / 수동 confirm).
|
||||
- 둘 다 없음 → 신규 Keycloak user 생성 (Review Profile 옵션에 따라 확인 페이지).
|
||||
9. (P1A 5와 동일) Keycloak → oauth2-proxy callback (`/oauth2/callback`, `code` 전달). oauth2-proxy → Keycloak `/token`. **Keycloak access token + refresh token + ID token 발급.**
|
||||
10. (P1A 6–7과 동일) oauth2-proxy: 세션 cookie 셋팅 + 헤더(`X-Auth-Request-Email` 등) 주입 후 backend로 forward. P1B 기본 계약은 bearer token을 전달하지 않고 backend가 edge header만 신뢰한다.
|
||||
|
||||
## 신뢰 경계 (P1A 대비 변화)
|
||||
|
||||
| 경계 | P1A | P1B |
|
||||
|------|-----|-----|
|
||||
| Browser ↔ oauth2-proxy | TLS, 세션 cookie | 동일 |
|
||||
| oauth2-proxy ↔ Keycloak | TLS, client secret | 동일 |
|
||||
| Keycloak ↔ Google | — | **신규.** TLS, Google OAuth client secret (Keycloak이 보관) |
|
||||
| oauth2-proxy ↔ Backend | trusted identity header (bearer token 미전달) | 동일 |
|
||||
| Backend의 token 검증 | 없음 — edge에서 인증 종결, header-only | 동일 — Google federation만 추가 |
|
||||
|
||||
신규 trust hop = **Google → Keycloak 한 개.** Google ID token signature는 Keycloak이 검증하고 oauth2-proxy는 Keycloak 세션을 만든다. 그 이후 backend trust는 P1A와 같은 header-only 경계다.
|
||||
|
||||
## 장점 / 단점 vs P1A
|
||||
|
||||
### 장점
|
||||
|
||||
- 사용자가 **Google 계정으로 로그인 가능** → 별도 비밀번호 관리 불필요. UX 개선.
|
||||
- 조직이 Google Workspace 사용 중이면 사실상의 SSO 통합 (사내 Google 계정 그대로 사용).
|
||||
- Keycloak이 brokering 하므로 **edge/backend 코드 변화 0** — P1A에서 Identity Provider만 추가 설정.
|
||||
- 다른 외부 IdP(Microsoft / GitHub) 추가 시에도 동일 패턴으로 확장 가능 (broker만 추가 등록).
|
||||
|
||||
### 단점
|
||||
|
||||
- **외부 의존:** Google OIDC downtime / rate limit 시 신규 로그인 불가 (이미 발급된 Keycloak 세션은 영향 없음).
|
||||
- **사용자 매핑 정책 운영 부담:** First Login Flow / Account Linking 정책 결정 필요. 잘못 설정 시 보안 이슈 (자동 email match linking → account takeover 위험).
|
||||
- **보안 surface 확장:**
|
||||
- Google OAuth client secret이 Keycloak DB(또는 vault)에 저장됨.
|
||||
- Google Cloud Console의 redirect URI 등록 관리 (환경별 OAuth client 분리 필요).
|
||||
- Google 측 권한 변경(예: scope 변경, OAuth verification 요구) 시 영향 받음.
|
||||
- **개인정보 / 동의 흐름 추가:** Google scope 동의 화면, GDPR 등 데이터 처리 정책 영향.
|
||||
- **디버깅 복잡도:** 로그인 실패 시 oauth2-proxy / Keycloak / Google 3-leg 중 어디서 실패했는지 추적 필요 (로그 corr id 설계 중요).
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
본 sub-branch는 문서까지만 (`documented-only`)이므로 실제 환경 결정은 없음. **만약 구현한다면** 권장 기본값:
|
||||
|
||||
- **D1 — First Login Flow / Review Profile:** OFF (Google이 email/profile 제공하므로 불필요). 단, 신규 사용자 동의 페이지가 필요한 비즈니스 요건이면 ON.
|
||||
- **D2 — Account Linking:** **수동 confirm.** 공식 문서가 "automatic linking by email = potential security hole" 명시. email match 시 사용자가 명시적으로 link 확인하도록.
|
||||
- **D3 — Google OAuth client 분리:** dev / staging / prod 환경별 별도 OAuth client. redirect URI 충돌 방지.
|
||||
- **D4 — scope:** `openid profile email`만. (추정 — 추가 scope 요청 시 Google OAuth verification 이 트리거될 수 있으나 인용 raw 가 enumerate 안 함, 별도 raw 확보 전까지 근거 미보증.)
|
||||
- **D5 — persistent federation key requirement:** [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] D1 — concrete key policy owner. 본 parent는 안정적인 외부 subject를 사용한다는 P1B invariant만 consume한다.
|
||||
|
||||
`needs-confirmation`:
|
||||
- Keycloak 세션 만료 시 Google refresh token으로 자동 갱신 가능 여부 (Keycloak이 Google refresh token을 보관하나? 정책상 사용자 재로그인이 일반적).
|
||||
- Google account 삭제 / suspend 시 Keycloak local user 자동 비활성화 여부.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> P1B 권장 기본값 결정과 raw source claim 매핑. company-tech-blog (`codemancers`) claim 은 보조 근거 — 공식 best practice 로 격상 금지 (CLAUDE.md §5).
|
||||
|
||||
> `선택 조건` 열(R2): 각 결정이 "어떤 조건일 때 이 값, 다른 조건이면 어떤 대안" 인지. 상세 근거는 §결정 사항 prose.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | First Login Flow / Review Profile = OFF (Google이 email/profile 제공하므로 불필요) | Google 이 `email`+`profile` 을 제공 → `Off`. 신규 사용자 동의/추가 attribute 수집이 비즈니스 요건이면 `On`, mandatory attr 부재 대비 fallback 만이면 `missing` (KC-FLF-C4 의 3-mode) | `raw/official-docs/keycloak-first-login-flow.md#KC-FLF-C4`, `raw/official-docs/keycloak-google-idp-setup.md#KC-GIDP-C5`, `raw/official-docs/google-openid-connect-oidc.md#GOIDC-C4` | `official-vendor-doc (Keycloak) + official-standard (Google OIDC)` | KC-FLF-C4 의 "mandatory information" 정확한 목록 (locale 포함 여부 등) 미확정 — `Off` mode 에서도 누락 시 자동 fallback 동작 확인 필요 |
|
||||
| D2 | Account Linking = 수동 confirm (자동 email link 금지) | 외부 IdP email 을 항상 신뢰할 수 없음이 기본(KC-FLF-C2 공식 경고) → 항상 Confirm Link. 자동 email link 는 통제된 신뢰 환경에서도 공식 경고 대상이라 채택 안 함 (대안 없음) | `raw/official-docs/keycloak-first-login-flow.md#KC-FLF-C2`, `raw/official-docs/keycloak-first-login-flow.md#KC-FLF-C3` | `official-vendor-doc` (Keycloak 공식 security warning 명시) | "Confirm Link Existing Account" 가 Keycloak version 별 default flow 에 포함되는지 vs 별도 추가인지 — KC-FLF-C3 의 "Does not prove" 에 명시 — version 별 확인 필요 |
|
||||
| D3 | dev / staging / prod 환경별 별도 Google OAuth client | 환경별 redirect URI/도메인이 다름 → 환경당 별도 OAuth client. 단일 도메인·단일 환경이면 client 1개 + 다중 redirect URI 로도 가능 | `raw/official-docs/keycloak-google-idp-setup.md#KC-GIDP-C3`, `raw/official-docs/keycloak-google-idp-setup.md#KC-GIDP-C4`, `raw/company-tech-blogs/keycloak-google-login-codemancers.md#CM-KC-GG-C4` (보조 — company case study, 공식 best practice 아님) | `official-vendor-doc (Keycloak) + company-case-study (codemancers, 보조)` | "환경별 분리가 redirect URI 충돌 방지에 필수" 는 일반 운영 원칙으로 raw claim 들이 직접 명시하지 않음 — KC-GIDP-C4 의 wildcard / 부분 매칭 허용 여부가 raw 범위 밖 |
|
||||
| D4 | scope = `openid profile email` 만 | `email`/`profile` 매핑만 필요 → Keycloak default 3 scope 유지. `hd`(도메인 제한)·groups 등 추가 사용자 데이터가 필요할 때만 scope 추가 | `raw/official-docs/keycloak-google-idp-setup.md#KC-GIDP-C5`, `raw/official-docs/google-openid-connect-oidc.md#GOIDC-C4` | `official-vendor-doc (Keycloak default) + official-standard (Google OIDC scope contract)` | "추가 scope 요청 시 Google OAuth verification 트리거" 는 본 raw 들이 직접 enumerate 하지 않음 — Google OAuth verification 정책 별도 raw 보존 필요 |
|
||||
| D5 | P1B는 stable external subject를 요구하며 concrete persistent federation key policy를 직접 소유하지 않음 | key 선택·변경은 child owner에서만 수행. parent는 그 결과를 P1B topology invariant로 consume | [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] D1 — persistent Google federation key owner | `delegated` | Keycloak 저장 메커니즘과 initial collision locator의 차이는 child에서 추적; parent에 복제하지 않음 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> **본 브랜치는 `documented-only` 설계 hub** — P1B topology와 pattern-level 요구만 소유하고 Google brokering의 mutable 설정은 자식 브랜치(§Cluster 4개)에 위임한다. 아래 parent D-row는 자식 owner의 값을 복제하지 않고 pointer로 consume한다. **모든 항목 등급 `planned`** — 구현 repo(`keycloak-patterns/`)가 아직 없어 코드로 확정된 것은 0개(`NO_GROUND_TRUTH`, ground truth = 공식 raw 문서). 코드 확인 후 등급 승격.
|
||||
>
|
||||
> **3-rule 준수**: 각 sub-section 은 Decision ID + Supporting Claim ID 를 Trace. 근거 raw 가 *원칙* 만 주고 *detail* 은 안 주는 cell 은 `UNSUPPORTED_IMPL_DECISION` + trade-off 한 줄. 본 브랜치 결정 범위 밖 detail(Java SPI custom code 등)은 §범위 out-of-scope 로 이미 배제.
|
||||
|
||||
### 1. Google IdP 등록 (realm config) — D3 · D4 + delegated D5
|
||||
|
||||
> **Trace**: D3(환경별 client)·D4(scope). [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] D1 — persistent federation key owner. IdP client 설정 owner는 [[raw/branch-notes/feature-keycloak-idp-brokering-google-client]].
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (a) redirect URI 정확한 path 형식 — raw 가 명시 안 함(KC-GIDP-C3 "does not prove"), Admin Console 자동표시값을 복사(Claim To Verify #6 가 추적). trade-off: path 를 하드코딩하면 `KC_HTTP_RELATIVE_PATH`/host 조합 변화에 취약 → UI 표시값 복사가 안전. (b) discovery endpoint cache/retry 동작 — GOIDC-C5 는 URL 만 줌(Claim To Verify #1). (c) 환경별 client 분리 — official 미보증 운영 추론(KC-GIDP-C3/C4 는 양방향 등록까지만). trade-off: 단일 client 다중 redirect URI 도 가능하나 환경 간 실수 유출 위험 → 환경별 분리가 안전측.
|
||||
|
||||
| 설정 항목 | 값 / 경로 | Trace | 등급 |
|
||||
|---|---|---|---|
|
||||
| IdP 등록 진입 | Admin Console → `Identity Providers` → `Add provider` → `Google` | KC-GIDP-C1 | `planned` |
|
||||
| Client 자격 | Google 발급 `Client ID` + `Client Secret` 을 Keycloak Google IdP 에 입력 (Keycloak DB/vault 보관) | KC-GIDP-C2, D3 | `planned` |
|
||||
| 환경 분리 | dev/staging/prod 각 환경별 Google OAuth client 별도 발급 (redirect URI 충돌 방지). **UNSUPPORTED_IMPL_DECISION** — official 미보증 운영 추론 | D3(운영 추론, KC-GIDP-C3/C4 는 양방향 등록까지만 L1 증명) · CM-KC-GG-C4(보조) | `planned` |
|
||||
| Redirect URI | Keycloak `Add Identity Provider` 페이지 표시값 → Google Cloud Console `Authorized redirect URIs` 에 복사. 예상 형식 `https://<keycloak-host>/realms/<realm>/broker/google/endpoint` (**UNSUPPORTED_IMPL_DECISION** — 형식은 raw 인용 밖, UI 표시값 신뢰) | KC-GIDP-C3, KC-GIDP-C4 | `planned` |
|
||||
| Scope | Default Scopes = `openid profile email` 유지, 추가 scope 금지(D4 조건) | KC-GIDP-C5, D4 | `planned` |
|
||||
| Discovery | "Import from URL" = `https://accounts.google.com/.well-known/openid-configuration` | GOIDC-C5 | `planned` (Claim To Verify #1) |
|
||||
|
||||
### 2. First Broker Login Flow hardening — D1 · D2
|
||||
|
||||
> **Trace**: D1(Review Profile)·D2(Confirm Link) / Claims `KC-FLF-C2`, `KC-FLF-C3`, `KC-FLF-C4`. Owner 자식: [[raw/branch-notes/feature-keycloak-first-broker-login-flow]].
|
||||
>
|
||||
> - **OUT_OF_BRANCH_SCOPE**: flow 구성의 정본은 [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D1 — silent auto-link 방지, [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D2 — verification profile, [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D4 — custom hard-reject SPI 없이 성립하는 core policy. SPI artifact가 없는 현재 scope에서 `email_verified=false` 전체 hard-reject를 parent acceptance로 주장하지 않는다.
|
||||
|
||||
| Flow 설정 | 값 | Trace | 등급 |
|
||||
|---|---|---|---|
|
||||
| Flow 편집 전 복제 | built-in "first broker login" flow 를 복제 후 수정 (원본 훼손 시 외부 IdP 전체 차단 위험) | KC-FLF 운영맥락(L90) | `planned` |
|
||||
| Review Profile authenticator | `Off` (Google 이 email+profile 제공). 동의 페이지 필요 시만 `On`, mandatory 부재 대비만이면 `missing` | KC-FLF-C4, D1 | `planned` |
|
||||
| Confirm Link Existing Account | required — 자동 email link 금지, 사용자 명시 confirm 강제 (info page: 다른 email 사용 vs link 확인) | KC-FLF-C2, KC-FLF-C3, D2 | `planned` |
|
||||
| linking trust policy | [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D4 — silent auto-link 방지 core를 consume. `email_verified=false` 전체 hard-reject는 custom SPI가 별도 채택될 때만 추가 | delegated | `documented-only`; SPI variant는 out-of-scope |
|
||||
|
||||
### 3. Account matching identifier (IdP mapper) — D5
|
||||
|
||||
> **Trace**: D5. [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] D1 — persistent federation key owner. [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D4 — claim→attribute mapper owner.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `sub` 를 federated identity primary key 로 매핑하는 정확한 Keycloak IdP mapper 타입/옵션 — 참조 raw 미포함(D5 Open Risk 가 `keycloak-identity-provider-mappers.md` 필요 명시). trade-off: mapper 타입 임의 선택 시 재로그인 매칭 실패 가능 → mapper raw 확보 후 자식 브랜치에서 확정.
|
||||
|
||||
- Persistent federation key와 mapper 타입/옵션은 위 두 owner D-row를 참조한다. 본 parent는 stable external subject requirement만 유지한다.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4 캡처용. 본 브랜치는 `documented-only` 지만, Google leg 추가로 P1A 대비 새 실패 경로가 생기고, 자식·형제 브랜치와 계약 의존이 있다.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **Google OIDC downtime / rate limit**: 신규 로그인 불가. 이미 발급된 Keycloak 세션은 영향 없음 (Google leg 는 최초 인증 시에만) — §장점/단점 근거.
|
||||
- **email 기반 auto-linking 계정 탈취**: opt-in AutoLink가 email 값을 무확인 연결하면 기존 계정 탈취 가능. 방어 정본은 [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D1과 [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D4이며, 이 parent는 `email_verified=true` hard-reject를 별도 acceptance로 두지 않는다.
|
||||
- **redirect URI mismatch**: Google Cloud Console `Authorized redirect URIs` ↔ Keycloak `/broker/google/endpoint` 불일치 시 Google 측 오류. 환경별 client 분리(D3)로 완화 (Claim To Verify #6).
|
||||
- **bearer token 경계 누출**: P1B baseline backend에는 access token 자체가 전달되지 않는다. `Authorization` header가 관측되거나 Google token이 backend까지 새면 [[raw/branch-notes/feature-keycloak-edge-forwardauth-no-google]] D3의 header-only trust 경계가 붕괴한 것이다(Claim To Verify #5).
|
||||
- **3-leg 디버깅 복잡도**: 로그인 실패 시 oauth2-proxy / Keycloak / Google 중 실패 지점 추적 필요 → corr-id 설계 (§장점/단점).
|
||||
- **세션·계정 수명 불일치** (`needs-confirmation`): Keycloak 세션 만료 시 Google refresh 자동 갱신 여부(Claim To Verify #2), Google 계정 삭제/suspend 시 Keycloak local user 미비활성(Claim To Verify #3).
|
||||
- **다른 계약 의존**:
|
||||
- **Root**: [[raw/branch-notes/feature-keycloak-patterns]] 의 고정 결정 F3(단일 공유 realm + 패턴당 client)·F4(confidential secret = env var, 미커밋)에 의존 — Google client secret 저장 정책은 F4 를 따름.
|
||||
- **자식(위임 — detail owner)**: [[raw/branch-notes/feature-keycloak-idp-brokering-google-client]] D3 — client 등록. [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D1 — silent auto-link 방지, [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D2 — verification profile, [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D4 — core linking policy. [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D4 — claim mapping. [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] D1 — persistent federation key. Parent는 topology와 요구만 소유한다.
|
||||
- **형제(브로커 로직 재사용)**: [[raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation]](P2B), [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]](P3B) — 동일 Google brokering 흐름. P3B 실 구현 시 `KC_HOSTNAME` 공개 host 강제(Google redirect_uri 검증)라는 배포측 추가 의존.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 본 sub-branch 는 `documented-only`. 만약 구현한다면 검증해야 할 주장 enumerate.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| Keycloak 의 Google IdP "Use discovery endpoint" 옵션이 `https://accounts.google.com/.well-known/openid-configuration` 으로 정상 OIDC discovery 수행 | `GOIDC-C5` 가 URL 명시했으나, Keycloak 의 discovery 호출 실제 동작 (cache TTL, retry 정책 등) 별도 검증 | Keycloak Admin Console > Identity Provider > Google > "Import from URL" 클릭 후 endpoint 자동 채워지는지 확인 + keycloak debug log 에서 GET 요청 확인 | `planned` |
|
||||
| Keycloak 세션 만료 시 Google refresh token 으로 자동 갱신 가능 여부 | 본 sub-branch §결정 사항 `needs-confirmation` 명시 — Keycloak 이 Google refresh token 을 보관하는지 정책 불명 | Keycloak Admin Console > Identity Provider > Google > "Store Tokens" 옵션 활성화 후 세션 만료 후 동작 관찰 + Keycloak DB `federated_identity` 테이블 검사 | `needs-confirmation` |
|
||||
| Google account 삭제 / suspend 시 Keycloak local user 자동 비활성화 여부 | 본 sub-branch §결정 사항 `needs-confirmation` 명시 — Keycloak 에 webhook / polling 메커니즘 없음 (일반적) | Google Cloud Console 에서 test account suspend 후 Keycloak login 시도 → 거부 여부 관찰 (예상: Keycloak 은 모름, login 시점에 Google 측 401 로만 차단) | `needs-confirmation` |
|
||||
| First Broker Login Flow 의 "Confirm Link Existing Account" authenticator 가 Keycloak 26.x default flow 에 포함됨 | `KC-FLF-C3` 가 authenticator 존재 명시했으나 version 별 default flow 포함 여부 별도 확인 필요 | Keycloak Admin Console > Authentication > Flows > "first broker login" flow 확인 + "Confirm Link Existing Account" step 존재 여부 | `planned` |
|
||||
| P1B가 P1A의 header-only backend trust를 유지하고 Google/Keycloak bearer token을 backend로 전달하지 않음 | [[raw/branch-notes/feature-keycloak-edge-forwardauth-no-google]] D3을 consume하지만 실제 proxy config는 아직 없음 | Google 로그인 후 oauth2-proxy/ingress config와 backend request capture에서 trusted headers 존재, `Authorization` 부재, direct spoof 요청 차단을 확인 | `planned` |
|
||||
| Google OAuth client 의 redirect URI 가 `/realms/<realm>/broker/google/endpoint` 형식과 정확히 일치 | `KC-IDP-BROKER-C2` 가 verbatim 부재 명시 — Admin UI 표시값을 신뢰 | Keycloak Admin Console > Identity Provider > Google 페이지의 "Redirect URI" 필드 값을 복사 → Google Cloud Console 의 Authorized redirect URIs 와 byte-level 일치 확인 | `planned` |
|
||||
| (Deferred SPI variant) `email_verified=false`를 flow 진입 즉시 hard-reject하는 custom authenticator를 별도 구현할 필요가 있는가 | 현재 corpus에 provider JAR/SPI artifact가 없고 core silent-link 방지는 [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D4로 성립 | 별도 제품 요구가 생길 때 SPI branch를 만들고 provider JAR, flow export, false-email negative E2E를 함께 검증 | `planned` (현재 acceptance 아님) |
|
||||
|
||||
|
||||
|
||||
## 외부 근거 / 대안 조사 (2026-05-25 — P1B Edge + Google IdP Brokering)
|
||||
|
||||
본 sub-branch의 **Edge ForwardAuth + Google IdP Brokering** 채택에 대한 외부 source. P1A에 외부 IdP federation을 추가하는 방식의 대안 비교.
|
||||
|
||||
- **채택 결정 (Keycloak IdP Brokering — Google을 외부 IdP로 등록)**:
|
||||
- [[raw/official-docs/keycloak-identity-brokering-overview-official]] — Keycloak Identity Broker 공식 (외부 IdP 등록 + first broker login flow)
|
||||
- [[raw/official-docs/keycloak-google-idp-setup]] — Keycloak Google IdP setup 절차
|
||||
- [[raw/official-docs/google-openid-connect-oidc]] — Google OIDC 표준 (issuer, scopes, claims)
|
||||
- [[raw/official-docs/keycloak-first-login-flow]] — First Broker Login Flow (Account Linking 정책)
|
||||
- [[raw/company-tech-blogs/keycloak-google-login-codemancers]] — Keycloak + Google 통합 실무 사례
|
||||
- **검토한 대안**:
|
||||
- **대안 1: SAML federation (Keycloak ↔ Google Workspace SAML)** — 엔터프라이즈 단일 사인온 표준. 그러나 Google OIDC가 더 단순.
|
||||
- **대안 2: Google OIDC 직접 (Keycloak 우회)** — 백엔드가 Google ID token 직접 검증. 장: Keycloak 운영 부담 0 / 단: 다중 IdP 통합 어려움, role mapping 직접 작성.
|
||||
- **대안 3: Auth0 / Okta (managed multi-IdP SaaS)** — 운영 완전 위임. 단: vendor lock-in, 비용.
|
||||
- **대안 4: oauth2-proxy `--provider=google` 직접** — Keycloak 없이 oauth2-proxy가 Google과 직접 통신. 장: Keycloak 제거 / 단: realm/role 관리 불가, 멀티 IdP 통합 불가.
|
||||
- **대안 5: AWS Cognito + Google federation** — AWS 종속, 동일 패턴.
|
||||
- **비교 핵심**: IdP Brokering의 **본질적 가치는 "코드 변경 없이 IdP 추가"**. SPA/백엔드는 Keycloak만 알면 되고, Google/SAML/LDAP 추가는 Keycloak admin 설정만. AutoLink는 별도 opt-in 위험 기능이며, 본 패턴은 [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D1/D4의 silent-link 방지 core를 따른다.
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급.
|
||||
|
||||
- [ ] P1B 다이어그램을 Mermaid sequence로 재작성 — 등급: `planned`
|
||||
- [ ] P1A vs P1B diff matrix (sequence 단계 / trust boundary / 운영 비용) — 등급: `planned`
|
||||
- [ ] First Login Flow 정책 분기 트리 도식화 — 등급: `documented-only` (구현 안 함)
|
||||
- [ ] Keycloak refresh / Google session 만료 상호작용 확인 — 등급: `needs-confirmation`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- Google ID token은 Keycloak이 검증한 뒤 backend로 전달하지 않는다. oauth2-proxy가 Keycloak token으로 session을 만들고, P1B backend는 [[raw/branch-notes/feature-keycloak-edge-forwardauth-no-google]] D3과 같은 **edge-injected header-only** 계약만 소비한다. 따라서 backend로 Google/Keycloak bearer token이 흐른다고 표현하지 않는다.
|
||||
- "Google 로그인 = backend가 Google과 통신"으로 오해하기 쉬움. 다이어그램에서 Google ↔ Keycloak leg를 별도 색/박스로 강조해야 함.
|
||||
- 본 sub-branch는 `documented-only` 한정 — wiki/projects/로 승급 안 함 (root TODO 참조).
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 없음 (구현 안 함).
|
||||
|
||||
## 관련 sub-branch
|
||||
|
||||
- [[raw/branch-notes/feature-keycloak-patterns]] (root)
|
||||
- [[raw/branch-notes/feature-keycloak-edge-forwardauth-no-google]] (P1A — Google 없는 동일 배치, 비교 기준)
|
||||
- [[raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation]] (P2B — 내부 배치 + Google, broker 로직은 본 노트와 동일)
|
||||
- [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]] (P3B — 단일 EC2 + Google)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크: (없음, 문서까지만)
|
||||
- 머지 결과 / 배포 환경: 없음 (`documented-only`)
|
||||
- **wiki 추출 대상:** 없음. P1B는 root 정책상 wiki/projects/ 승급 안 함.
|
||||
- **추출하지 않을 항목:** 본 sub-branch 전체 (`documented-only` / `planned`).
|
||||
+372
@@ -0,0 +1,372 @@
|
||||
---
|
||||
title: branch / feature-keycloak-edge-forwardauth-no-google (P1A Edge Forward Auth, no Google)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-CHILD-12F5B5DA
|
||||
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-edge-forwardauth-no-google
|
||||
parent_branch: feature-keycloak-patterns
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, auth, oauth2, oidc]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: e8d7d4e1b0766c23adb5879a98225689106a683f5882dd49a3b4b24b5fc18f08
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-edge-forwardauth-no-google (P1A Edge Forward Auth, no Google)
|
||||
|
||||
> Layer: `raw/branch-notes/` — Keycloak 패턴 P1A 한정 sub-branch. **Ingress(nginx `auth_request` 또는 Traefik `forwardAuth`)가 외부 auth service(oauth2-proxy baseline)에 인증을 위임**하고 백엔드는 인증 코드를 갖지 않는 패턴. Google federation 없음(=P1B는 별도 sub-branch).
|
||||
> 본 sub-branch는 **문서까지만**(=`documented-only`). 실제 ingress/Traefik 환경 구축은 root branch의 P3A 한정.
|
||||
> `status_label`: `in-progress`
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- **Parent branch (root)**: [[raw/branch-notes/feature-keycloak-patterns]]
|
||||
- **Parent project**: [[raw/project-notes/keycloak-patterns-overview]]
|
||||
- **Sibling sub-branches**:
|
||||
- [[raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation]] — P1B (Edge + Google)
|
||||
- [[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]] — P2A (Cluster-internal, no Google)
|
||||
- [[raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation]] — P2B (Cluster-internal + Google)
|
||||
- [[raw/branch-notes/feature-keycloak-single-ec2-no-google]] — P3A (Single EC2, no Google)
|
||||
- [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]] — P3B (Single EC2 + Google)
|
||||
|
||||
<!-- 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 변형이다 | no-Google Edge ForwardAuth를 AP4 비교 자료로 분류한다 | [[raw/project-notes/keycloak-patterns-overview]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
| D1 | ingress 축과 auth-service 축을 분리한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
| D2 | upstream identity header naming을 고정한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
| D3 | backend authentication을 edge ForwardAuth에 위임한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
| D4 | ingress-only traffic으로 header spoofing을 방어한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
없음.
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
Edge forward-auth 패턴이 무엇이고, 왜 이 배치를 택하는지를 컴포넌트 다이어그램 + 토큰 교환 sequence + 신뢰 경계 수준으로 정리. 백엔드 코드에서 인증 로직을 제거하고 **edge proxy 단일 지점에서 zero-trust ingress** 를 강제하는 흐름을 면접에서 설명할 수 있어야 함.
|
||||
|
||||
핵심 질문 두 개에 답할 수 있어야 한다:
|
||||
1. 왜 백엔드에 JWT validator를 두지 않고 edge proxy에 인증을 위임하는가? → 다중 서비스에 일관 인증 + 인증 코드 0줄.
|
||||
2. edge proxy를 신뢰하는 대신 잃는 것은? → 백엔드는 헤더만 보고 사용자를 식별하므로, ingress 우회 경로가 있으면 헤더 spoofing 위험.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- P1A 컴포넌트 다이어그램 (텍스트 + Mermaid)
|
||||
- 토큰 교환 sequence 7단계
|
||||
- Ingress 선택(nginx vs Traefik)과 auth service 선택(oauth2-proxy vs 호환 OIDC agent)의 2축 비교
|
||||
- nginx `auth_request` 방식과 Traefik `forwardAuth` 방식의 차이
|
||||
- 신뢰 경계 정의 (ingress → proxy까지)
|
||||
- 장단점 / 운영 비용 / 보안 surface
|
||||
- 외부 공식 문서 raw 보존 (oauth2-proxy, Traefik, nginx)
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- Google IdP brokering (=P1B sub-branch에서 다룸)
|
||||
- 실제 K8s / docker-compose 환경 구축 (root branch P3A 한정)
|
||||
- BFF 패턴 (별도 결합 패턴, sub-branch에서 언급만)
|
||||
- mTLS / FAPI / DPoP 등 고급 보안 옵션
|
||||
- oauth2-proxy 비-Keycloak provider (GitHub, Google direct 등)
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 본 sub-branch의 P1A Edge ForwardAuth 패턴 채택 근거. 상세 대안 비교는 §외부 근거 / 대안 조사 참조.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/oauth2-proxy-overview-config-official]] | oauth2-proxy 공식 — 채택 컴포넌트 근거 (load-bearing: `OAUTH2PROXY-C2`/`C3`; `C1` 은 `needs-confirmation`, 결정 미인용) |
|
||||
| [[raw/official-docs/oauth2-proxy-keycloak-oidc-provider-official]] | oauth2-proxy ↔ Keycloak OIDC 연동 — `provider=keycloak-oidc` 채택 근거 |
|
||||
| [[raw/official-docs/oauth2-proxy-nginx-integration-official]] | nginx auth_request 통합 — Ingress-Nginx 결합 근거 |
|
||||
| [[raw/official-docs/nginx-auth-request-module-official]] | nginx ngx_http_auth_request_module — subrequest 동작 근거 |
|
||||
| [[raw/official-docs/traefik-forwardauth-middleware-official]] | Traefik ForwardAuth middleware — K8s 환경 대안 비교 근거 |
|
||||
|
||||
## 컴포넌트 다이어그램
|
||||
|
||||
### 텍스트
|
||||
|
||||
```
|
||||
Browser → Ingress (Nginx / Traefik)
|
||||
│
|
||||
├── (1) ForwardAuth subrequest → oauth2-proxy / compatible auth service
|
||||
│ │
|
||||
│ └── (2) OIDC handshake → Keycloak
|
||||
│ │
|
||||
│ ← (3) 세션 쿠키 + X-Auth-Request-* 헤더 ←┘
|
||||
│
|
||||
↓ (4) 인증 통과 시 backend로 forward (헤더만 신뢰)
|
||||
Backend (인증 코드 0줄, 헤더 trust만)
|
||||
```
|
||||
|
||||
### Mermaid
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
B[Browser] -->|HTTPS| I[Ingress: Nginx or Traefik]
|
||||
I -. auth_request or forwardAuth .-> P[oauth2-proxy / auth service]
|
||||
P -. OIDC .-> K[Keycloak]
|
||||
P -- 202 + X-Auth-Request-* --> I
|
||||
I -->|trusted headers| BE[Backend API]
|
||||
```
|
||||
|
||||
## 토큰 교환 sequence
|
||||
|
||||
> "Browser, Ingress, oauth2-proxy, Keycloak, Backend" 5개 액터 기준.
|
||||
|
||||
1. **Browser → Ingress**: unauthenticated request `GET /api/orders` (쿠키 없음).
|
||||
2. **Ingress → oauth2-proxy `/oauth2/auth`**: nginx의 `auth_request` 디렉티브 또는 Traefik의 `forwardAuth` 미들웨어가 subrequest 전송. 이 endpoint는 **요청을 프록시하지 않고** 202(Accepted) 또는 401(Unauthorized)만 반환.
|
||||
3. **oauth2-proxy → Keycloak `/protocol/openid-connect/auth`**: 쿠키 없으므로 401 → Ingress가 error_page로 받아 named location `@oauth2_signin`으로 302 redirect 발급. 사용자 브라우저가 Keycloak 로그인 페이지로 이동.
|
||||
4. **Browser → Keycloak 로그인 UI → 사용자 인증 → callback**: Authorization Code Flow + PKCE. Keycloak이 oauth2-proxy의 callback URL (`/oauth2/callback`)로 `code` 파라미터와 함께 redirect.
|
||||
5. **oauth2-proxy → Keycloak `/protocol/openid-connect/token`**: `code` + `client_secret` → `access_token` + `id_token` + (옵션) `refresh_token` 교환. oauth2-proxy는 confidential client.
|
||||
6. **oauth2-proxy → 세션 쿠키 발급**: JavaScript가 raw token을 읽지 못하는 HttpOnly 세션 쿠키(`_oauth2_proxy`)를 발급한다. cookie-backed store면 encrypted cookie가 token material을 보유할 수 있고, Redis/server-side store면 cookie는 opaque session identifier만 보유한다. 이후 `auth_request` subrequest 통과 시 `X-Auth-Request-User`, `X-Auth-Request-Email`, `X-Auth-Request-Groups` 헤더를 Ingress에 응답한다(P1A baseline은 access-token upstream 전달 미사용).
|
||||
7. **Ingress → Backend**: Ingress가 응답 헤더에서 `auth_request_set` 으로 변수 추출 → `proxy_set_header X-User $user; X-Email $email;` 형식으로 backend에 헤더 주입. **백엔드는 JWT 검증을 하지 않고 헤더만 신뢰**.
|
||||
|
||||
## 장점 / 단점
|
||||
|
||||
### 장점
|
||||
|
||||
- **백엔드 인증 코드 0줄**: Resource Server 보일러플레이트(spring-security-oauth2-resource-server, JWT decoder, JWKS cache 등) 불필요.
|
||||
- **다중 서비스 일관 인증**: 같은 ingress 뒤의 모든 backend에 동일한 인증 정책 적용. 마이크로서비스 환경에서 인증 코드 분산을 방지.
|
||||
- **raw token의 JavaScript 노출 없음**: HttpOnly cookie라 SPA script가 access/refresh token bytes를 직접 읽지 못한다. 다만 cookie-backed session이면 브라우저가 encrypted token-bearing cookie를 보유하므로 server-side custody와 동일하다고 표현하지 않는다.
|
||||
- **운영 일원화**: 인증 정책 변경(allowed-role, allowed-group 등) 시 oauth2-proxy config만 수정.
|
||||
|
||||
### 단점
|
||||
|
||||
- **헤더 spoofing risk**: 백엔드가 ingress-only traffic을 강제하지 못하면(예: backend가 직접 NodePort 노출), 공격자가 `X-Auth-Request-User: admin` 헤더를 위조해 우회 가능.
|
||||
- **proxy SPOF**: oauth2-proxy 다운 시 모든 backend 접근 불가. HA 구성 필수.
|
||||
- **세션 저장 방식별 위험**: cookie-backed store가 access token까지 encrypted cookie에 담으면 nginx의 기본 4kb 헤더 한도를 넘어 split-cookie 처리가 필요하다. Redis/server-side store면 cookie는 opaque ID지만 외부 state store 운영 책임이 생긴다.
|
||||
- **백엔드가 토큰 claim 직접 접근 불가**: scope / custom claim 기반 fine-grained 권한 체크가 필요하면 추가로 `X-Auth-Request-Access-Token` 헤더로 토큰 자체를 전달하거나, 결국 backend에서도 JWT 파싱해야 함.
|
||||
|
||||
## 신뢰 경계
|
||||
|
||||
```
|
||||
[ Public Internet ] ←→ [ Ingress + Forward-Auth Proxy ] ←→ [ Backend ]
|
||||
untrusted ← 인증 경계 (boundary) trusted-by-header
|
||||
```
|
||||
|
||||
- **인증 boundary**: ingress → proxy 까지. 이 구간에서 사용자 식별 확정.
|
||||
- **백엔드 전제**: ingress 외 경로로는 도달 불가. 구체적 강제 수단:
|
||||
- K8s: `NetworkPolicy` 로 ingress namespace에서만 backend pod 접근 허용.
|
||||
- VM: backend listen address를 loopback / private subnet으로 한정. Security Group으로 ingress IP만 허용.
|
||||
- **이 전제가 깨지면** 패턴 전체가 깨짐 → 외부 공격자가 backend에 직접 `X-Auth-Request-User: anyuser` 헤더로 요청 가능.
|
||||
|
||||
## 제외 범위 (재확인)
|
||||
|
||||
- Google federation은 **P1B 별도 sub-branch** ([[raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation]]). 본 sub-branch는 Keycloak 자체 user store만 사용.
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [x] P1A 컴포넌트 다이어그램 (텍스트 + Mermaid) — 등급: `documented-only`
|
||||
- [x] 토큰 교환 sequence 7단계 — 등급: `documented-only`
|
||||
- [x] Ingress(nginx/Traefik)와 auth service(oauth2-proxy/호환 agent) 선택축 분리 (D1) — 등급: `documented-only`
|
||||
- [x] 신뢰 경계 정의 + header spoofing 방어(D4)를 자식 branch 로 위임 — 등급: `documented-only`
|
||||
- [ ] 실 구현(oauth2-proxy config + nginx `auth_request` block) — root branch **P3A** 한정 — 등급: `planned`
|
||||
- [ ] §Claims To Verify 5개 주장 실측 (nginx build flag / 4kb cookie / 헤더 전파 / ingress-only / Traefik 비-2XX) — 등급: `planned` (일부 `needs-confirmation`)
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- **P1A 의 본질 한 줄**: edge proxy 가 인증을 종결하고 backend 는 헤더만 신뢰 → "backend 인증 코드 0줄" 이 최대 이점이자 동시에 최대 약점(header spoofing). 이 한 문장이 §장점·§단점·D3·D4 를 관통한다.
|
||||
- **subrequest mode endpoint 구분**: oauth2-proxy 의 `/oauth2/auth` 는 요청을 프록시하지 않고 2xx/401 만 반환하는 subrequest 전용 endpoint(`O2PN-C2`)로, 정상 reverse-proxy mode(`/oauth2/start`,`/oauth2/callback`)와 경로가 다르다. nginx `auth_request` 는 이 endpoint 만 부른다 — 두 mode 를 혼동하면 302 루프가 난다.
|
||||
- **4kb cookie 함정**: cookie-backed store가 access token을 encrypted cookie에 실으면 nginx 기본 헤더 한도(4kb)를 넘어 split cookie가 되고, nginx가 첫 `Set-Cookie`만 복사하는 문제(`O2PN-C6`)가 있다. Redis/server-side store에서는 이 크기 위험 대신 state-store 운영 위험을 검증한다.
|
||||
- 본 sub-branch 는 `documented-only`. `wiki/projects/` 승급은 root 의 6-패턴 비교 매트릭스 시점에 일괄 처리(개별 승급 없음).
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- **D1** 2026-05-25: 선택을 두 축으로 분리한다.
|
||||
- **Ingress 축**: Ingress-Nginx면 `auth_request`, Traefik이면 `forwardAuth` middleware를 사용한다.
|
||||
- **Auth service 축**: oauth2-proxy를 baseline OIDC agent로 두며, 다른 호환 auth service를 쓰려면 동일한 allow/deny·header contract를 검증한다.
|
||||
- Traefik `forwardAuth`는 OIDC session provider 자체가 아니라 외부 auth service를 호출하는 middleware다. 따라서 `Traefik + oauth2-proxy`는 정상 조합이며 상호 배타적 대안이 아니다.
|
||||
- **D2** 2026-05-25: 헤더 이름은 nginx 측 `X-Auth-Request-User` 가 사실상 표준 (oauth2-proxy 응답 헤더). Traefik의 `X-Forwarded-User`는 oauth2-proxy 측 옵션 `--pass-user-headers`가 추가 발급하는 헤더로, 본 문서에서는 nginx 계열 명명 우선.
|
||||
- **D3** 2026-05-25: 백엔드 인증 코드를 제거하고 edge proxy 의 ForwardAuth subrequest 로 인증 위임 (Edge ForwardAuth 패턴 채택).
|
||||
- **D4** 2026-05-25: header spoofing 방어를 위해 ingress-only traffic 강제 (K8s NetworkPolicy 또는 VPC SG) — backend 가 ingress 외 경로로 도달 불가해야 함.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 본 sub-branch 의 P1A Edge ForwardAuth 패턴 채택 결정과 raw source claim 매핑. claim 형식 `raw/<category>/<slug>.md#<CLAIM-ID>`.
|
||||
|
||||
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|
|
||||
| D1 | Ingress 축(nginx `auth_request` / Traefik `forwardAuth`)과 auth service 축(oauth2-proxy / compatible OIDC agent)을 분리. baseline은 두 ingress 모두 oauth2-proxy 호출 | `raw/official-docs/oauth2-proxy-overview-config-official.md#OAUTH2PROXY-C2`, `raw/official-docs/oauth2-proxy-overview-config-official.md#OAUTH2PROXY-C3`, `raw/official-docs/oauth2-proxy-keycloak-oidc-provider-official.md#O2PK-C1`, `raw/official-docs/oauth2-proxy-keycloak-oidc-provider-official.md#O2PK-C2`, `raw/official-docs/oauth2-proxy-nginx-integration-official.md#O2PN-C1`, `raw/official-docs/traefik-forwardauth-middleware-official.md#TFA-C1`, `raw/official-docs/traefik-forwardauth-middleware-official.md#TFA-C3`, `raw/official-docs/traefik-forwardauth-middleware-official.md#TFA-C4` | `official-vendor-doc` (3 vendors: oauth2-proxy + Keycloak + Traefik) | ingress별 비-2XX 처리 차이와 auth service 대체 호환성은 실측 필요 |
|
||||
| D2 | 헤더 명명은 nginx 계열 `X-Auth-Request-User` (oauth2-proxy 응답 헤더) 우선 | `raw/official-docs/oauth2-proxy-overview-config-official.md#OAUTH2PROXY-C3`, `raw/official-docs/oauth2-proxy-overview-config-official.md#OAUTH2PROXY-C4`, `raw/official-docs/oauth2-proxy-nginx-integration-official.md#O2PN-C3` | `official-vendor-doc` | `X-Auth-Request-*` (subrequest mode response headers) vs `X-Forwarded-*` (`--pass-user-headers` upstream forwarding) 의 정확한 default 활성화 여부는 OAUTH2PROXY-C4 의 "Does not prove" 에 명시 — 별도 config 확인 필요 |
|
||||
| D3 | 백엔드 인증 코드 제거 + edge proxy 의 ForwardAuth subrequest 로 인증 위임 (Edge ForwardAuth 패턴 채택) | `raw/official-docs/nginx-auth-request-module-official.md#NGAR-C1`, `raw/official-docs/nginx-auth-request-module-official.md#NGAR-C2`, `raw/official-docs/nginx-auth-request-module-official.md#NGAR-C4`, `raw/official-docs/nginx-auth-request-module-official.md#NGAR-C5`, `raw/official-docs/oauth2-proxy-nginx-integration-official.md#O2PN-C2`, `raw/official-docs/oauth2-proxy-nginx-integration-official.md#O2PN-C5`, `raw/official-docs/traefik-forwardauth-middleware-official.md#TFA-C1` | `official-standard (nginx) + official-vendor-doc (oauth2-proxy, Traefik)` | "다중 백엔드에 일관 인증" / "백엔드 코드 0줄" 의 운영상 이점은 본 raw claim 들이 직접 enumerate 하지 않음 — 운영 관행 추론 |
|
||||
| D4 | header spoofing 방어 — ingress-only traffic 강제 (NetworkPolicy / VPC SG) | UNSUPPORTED_DECISION (오current Sources 표 에는 NetworkPolicy / VPC SG enforcement 의 공식 raw 가 없음 — sub-sub-branch `feature-keycloak-header-spoofing-defense` 에서 별도 raw 보존 필요) | `internal-reasoning` (보안 일반 원칙) | NetworkPolicy default deny / VPC SG 정확한 구성 패턴이 본 sub-branch 의 Sources 표에 부재. 구현 단계 진입 전 K8s NetworkPolicy 공식 doc + AWS SG 공식 doc 을 raw 로 보존 필요 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 본 sub-branch 는 `documented-only` — 산출물은 코드가 아니라 패턴 문서다. 아래는 root branch **P3A** 에서 실제 구현 시 이 branch 의 결정(D1~D4)이 강제하는 config 앵커의 사전 명세. 모든 항목 등급 `planned`(코드 미존재 — `src/` grep 으로 확정 안 됨).
|
||||
> **`NO_GROUND_TRUTH`**: 본 branch 는 `keycloak-patterns` 학습 프로젝트 소속으로 ca-tmpl skeleton 범위 밖 인프라 설정이다 — error-codes/env-keys/headers registry 등 ca-tmpl 계약 SSOT 대조 대상이 아니며, 근거는 vendor 공식 doc(oauth2-proxy / nginx / Traefik)이다.
|
||||
|
||||
### 1. oauth2-proxy config (K8s + Ingress-Nginx 경로)
|
||||
|
||||
> **Trace**: D1(nginx ingress + oauth2-proxy baseline) + D2(헤더 명명 nginx 계열). Supporting: `OAUTH2PROXY-C2`, `O2PK-C1`, `O2PK-C2`, `O2PN-C3`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음 — 아래 파라미터·값은 vendor doc 이 verbatim 명시.
|
||||
|
||||
| 설정 | 값 | 근거 claim |
|
||||
|---|---|---|
|
||||
| `--provider` | `keycloak-oidc` | `O2PK-C1` |
|
||||
| `--client-id` / `--client-secret` / `--oidc-issuer-url` | confidential client 3종 필수 파라미터 | `O2PK-C1` |
|
||||
| issuer URL 패턴 | Keycloak 17+ `https://<host>/realms/<realm>` (17 미만 legacy `/auth/realms/...`) | `O2PK-C2` |
|
||||
| `--set-xauthrequest` | 활성 — `X-Auth-Request-User`/`-Email` 응답 헤더 발급 (subrequest mode) | `O2PN-C3` |
|
||||
|
||||
### 2. nginx `auth_request` location block
|
||||
|
||||
> **Trace**: D3(edge ForwardAuth 로 인증 위임). Supporting: `NGAR-C1`, `NGAR-C2`, `NGAR-C4`, `NGAR-C5`, `O2PN-C2`, `O2PN-C5`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: named location 이름(`@oauth2_signin`)은 관례적 명명 — 임의 선택 가능(vendor 예시 관용값). 강제되는 것은 이름이 아니라 "401 → 302 redirect" contract(`O2PN-C5`)뿐. trade-off: 관용값을 벗어나면 남의 예시 config 를 그대로 못 붙임.
|
||||
|
||||
| 디렉티브 | 역할 | 근거 claim |
|
||||
|---|---|---|
|
||||
| `auth_request /oauth2/auth;` | 보호 location 에서 subrequest 발사 (URI = oauth2-proxy subrequest endpoint) | `NGAR-C4`, `O2PN-C2` |
|
||||
| subrequest 응답 contract | 2xx=allow, 401/403=deny | `NGAR-C2`, `O2PN-C2` |
|
||||
| `auth_request_set $user $upstream_http_x_auth_request_user;` (+ `$email`) | subrequest 응답 헤더 → main request 변수 | `NGAR-C5`, `O2PN-C3` |
|
||||
| `proxy_set_header X-User $user;` (+ `X-Email $email;`) | backend 로 사용자 식별 헤더 주입 | `O2PN-C3` |
|
||||
| `error_page 401 = @oauth2_signin;` → `return 302 /oauth2/sign_in?rd=...` | 미인증 브라우저 302 redirect | `O2PN-C5` |
|
||||
| nginx build | `--with-http_auth_request_module` 필수 (기본 빌드 미포함) | `NGAR-C1` · 검증 → §Claims 1 (`NGAR-C7`) |
|
||||
|
||||
### 3. Traefik `forwardAuth` ingress variant (auth service는 별도)
|
||||
|
||||
> **Trace**: D1(Traefik ingress 축). `forwardAuth.address`는 oauth2-proxy 또는 호환 auth service를 가리킨다. Supporting: `TFA-C1`, `TFA-C3`, `TFA-C4`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: nginx(401/403 만 deny)와 Traefik(모든 non-2XX 를 302 포함 client 에 그대로 전달)의 비-2XX 처리 contract 차이는 vendor doc 이 명시(`TFA-C1`)하나, 두 경로가 동일 로그인 UX 를 내는지는 미실측 → §Claims 5. trade-off: 두 경로를 "동등"으로 문서화하려면 이 실측이 선행.
|
||||
|
||||
| 미들웨어 옵션 | 역할 | 근거 claim |
|
||||
|---|---|---|
|
||||
| `forwardAuth.address` | oauth2-proxy `/oauth2/auth` 로 위임, 2XX=allow + 원본 요청 진행 | `TFA-C1` |
|
||||
| `authResponseHeaders` | 인증 서버 응답 헤더(`X-Auth-Request-*`)를 forwarded request 로 복사 (충돌 헤더 대체) | `TFA-C3` |
|
||||
| `authRequestHeaders` | 인증 서버로 전달할 request 헤더 필터 (비우면 전부 전달 — sensitive 헤더 노출 주의) | `TFA-C4` |
|
||||
|
||||
### 4. ingress-only 강제 (header spoofing 방어) — 자식 branch 로 위임
|
||||
|
||||
> **Trace**: D4(`UNSUPPORTED_DECISION`).
|
||||
>
|
||||
> - **R3 OUT_OF_BRANCH_SCOPE**: NetworkPolicy default-deny / VPC SG 의 구체 구성은 P1A 결정 범위 밖 — 자식 sub-sub-branch [[raw/branch-notes/feature-keycloak-header-spoofing-defense]] 가 owner. 본 branch 는 "ingress 외 경로로 backend 도달 불가를 강제해야 한다"는 원칙만 명시하고 enforcement detail 은 재진술하지 않는다(포인터만).
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4(깊이 게이트) 캡처. 정상 경로(§토큰 교환 sequence) 외 구현 중 부딪힐 실패·엣지 + 다른 계약 의존.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **proxy SPOF**: oauth2-proxy 다운 → 같은 ingress 뒤 모든 backend 접근 불가(§단점). 기대 동작: HA(replica ≥2) + readiness probe. 미구성 시 단일 장애점.
|
||||
- **4kb cookie 초과 / split cookie**: access_token 을 cookie 에 실으면 nginx 헤더 한도 초과 → nginx 가 첫 `Set-Cookie` 만 복사(`O2PN-C6`). 기대 동작: cookie 분할 처리 또는 access_token 을 cookie 에 싣지 않음. → §Claims 2.
|
||||
- **미인증 XHR/API 요청**: `O2PN-C5` 의 401→302 redirect 는 브라우저 전제. API client 는 302 를 따라가지 못함. 기대 동작: `Accept: application/json` 요청엔 401 유지(별도 처리 — `O2PN-C5` "Does not prove" 참조).
|
||||
- **nginx build 에 auth_request 모듈 부재**: 기본 빌드 미포함(`NGAR-C7`) → `auth_request` directive 무효화. 기대 동작: 기동 시 config 오류로 조기 실패. → §Claims 1.
|
||||
- **nginx vs Traefik 비-2XX contract 차이**: oauth2-proxy 가 5xx 반환 시 nginx(401/403 만 deny, 그 외 error)와 Traefik(모든 non-2XX 를 client 에 전달)의 최종 응답이 갈림(`TFA-C1`). → §Claims 5.
|
||||
- **다른 계약 의존** (대상 브랜치 + 그 Decision ID 병기):
|
||||
- [[raw/branch-notes/feature-keycloak-header-spoofing-defense]] `D1`(백엔드 ingress-뒤 격리 = 1차 방어) + `D3`(K8s NetworkPolicy default-deny) / `D4`(EC2 Security Group + private-subnet listen) 에 의존 — 이 계약이 없으면 본 branch `D3`(헤더 trust)의 전제가 깨져 외부에서 `X-Auth-Request-User` 위조가 가능해지고 P1A 패턴 전체가 무력화된다. 본 branch `D4` 는 원칙만 선언, enforcement detail 은 이 자식 owner.
|
||||
- [[raw/branch-notes/feature-keycloak-oauth2-proxy-oidc-flow]] `D2`(oauth2-proxy `provider=keycloak-oidc` + `--client-id/-secret/-oidc-issuer-url` + Keycloak 17+ issuer URL 패턴)가 §sequence step 3~6 handshake 의 owner. 이 계약이 바뀌면 본 branch §구현 가이드 1 의 config 앵커(D1/D2)가 영향받음.
|
||||
- [[raw/branch-notes/feature-keycloak-nginx-auth-request-integration]] `D2`(subrequest 2xx/401/403 contract) + `D3`(`auth_request_set`→`proxy_set_header` 2-step 헤더 전파) + `D5`(4kb cookie split 대응)가 본 branch §구현 가이드 2(nginx `auth_request` block)의 owner.
|
||||
- **P1B 변형** [[raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation]] — Google federation을 추가해도 [[raw/branch-notes/feature-keycloak-edge-forwardauth-no-google]] D3의 header-only backend trust를 그대로 consume한다.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 본 sub-branch 는 `documented-only` 단계. 구현 진입 시 검증해야 할 주장 enumerate.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| nginx 빌드에 `--with-http_auth_request_module` 가 활성화되어 있음 | `NGAR-C7` 명시 — 기본 빌드에 포함되지 않음 | `nginx -V 2>&1 \| grep -o with-http_auth_request_module` | `planned` (구현 진입 시) |
|
||||
| oauth2-proxy 가 큰 access_token (Keycloak refresh token 포함) 을 cookie 4kb 한도 내에서 처리 또는 split | `O2PN-C6` 명시 — 분할 cookie 시 nginx 가 첫 `Set-Cookie` 만 복사 | docker-compose 환경에서 큰 토큰 발급 후 브라우저 cookie 확인 + nginx access_log 의 Set-Cookie 헤더 검사 | `planned` |
|
||||
| `--set-xauthrequest` 활성화 시 nginx `auth_request_set` 이 backend 까지 `X-User` / `X-Email` 헤더 전파 | `O2PN-C3` 가 contract 명시했으나 실제 nginx config 의 `proxy_set_header` 작성 필요 | backend 에 echo endpoint 추가 후 curl 로 헤더 확인 | `planned` |
|
||||
| backend 가 ingress-only traffic 만 받음 (header spoofing 우회 차단) | D4 의 UNSUPPORTED_DECISION 와 동일 — 정책 enforcement 가 실제로 강제되는지 별도 검증 필요 | K8s: NetworkPolicy default-deny 적용 후 다른 namespace 에서 curl 시도 → 차단 확인. VM: backend listen address 가 loopback / private subnet 인지 `ss -tln` 확인 | `needs-confirmation` |
|
||||
| Traefik `forwardAuth` 의 비-2XX 응답 처리가 nginx `auth_request` 와 호환 가능 | `TFA-C1` 가 "비 2XX 응답은 그대로 client 에 반환" 명시 — nginx 의 "401/403 만 deny, 그 외 error" 와 contract 차이 | 두 환경에서 oauth2-proxy 가 5xx 반환 시 client 가 받는 응답 비교 (curl -v) | `planned` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 아직 없음(문서 단계).
|
||||
|
||||
## 묶음 (자식 sub-sub-branches)
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/nginx-auth-request-module-official]]
|
||||
- [[raw/official-docs/oauth2-proxy-endpoints-official]]
|
||||
- [[raw/official-docs/oauth2-proxy-keycloak-oidc-provider-official]]
|
||||
- [[raw/official-docs/oauth2-proxy-nginx-integration-official]]
|
||||
- [[raw/official-docs/oauth2-proxy-overview-config-official]]
|
||||
- [[raw/official-docs/traefik-forwardauth-middleware-official]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
- [[raw/branch-notes/feature-keycloak-oauth2-proxy-oidc-flow]] — oauth2-proxy 구성과 OIDC 흐름
|
||||
- [[raw/branch-notes/feature-keycloak-nginx-auth-request-integration]] — nginx auth_request 통합 (4kb cookie 함정)
|
||||
- [[raw/branch-notes/feature-keycloak-header-spoofing-defense]] — 헤더 spoofing 방어 (NetworkPolicy / SG / mTLS)
|
||||
- [[raw/branch-notes/feature-keycloak-traefik-forwardauth-alternative]] — Traefik ForwardAuth 대안 비교
|
||||
|
||||
> Sources / 근거 자료는 본 문서 하단 "외부 근거 / 대안 조사" 섹션 참조. Errors / Interview prep / Lectures 는 현재 없음 (Phase 3 P3A 실 구현 또는 외부 산출물 단계에 누적 예정).
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 관련 sub-branch
|
||||
|
||||
- [[raw/branch-notes/feature-keycloak-patterns]] (root)
|
||||
- [[raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation]] — P1B Edge + Google federation (본 패턴의 federation 변형)
|
||||
|
||||
## 외부 근거 / 대안 조사 (2026-05-25 — P1A Edge Forward Auth)
|
||||
|
||||
본 sub-branch의 **Edge ForwardAuth 패턴** 채택에 대한 외부 source 조사. 대안은 동일 목적(브라우저 인증 + 백엔드 신뢰)을 다른 방식으로 달성하는 패턴들과 비교.
|
||||
|
||||
- **채택 결정 (nginx `auth_request` 또는 Traefik `forwardAuth` ingress + oauth2-proxy baseline + Keycloak)**:
|
||||
- [[raw/official-docs/oauth2-proxy-overview-config-official]] — oauth2-proxy 공식 (Reverse proxy + auth provider integration)
|
||||
- [[raw/official-docs/oauth2-proxy-keycloak-oidc-provider-official]] — oauth2-proxy ↔ Keycloak OIDC 연동
|
||||
- [[raw/official-docs/oauth2-proxy-nginx-integration-official]] — nginx auth_request 통합
|
||||
- [[raw/official-docs/nginx-auth-request-module-official]] — nginx ngx_http_auth_request_module (2xx=allow / 401|403=deny contract)
|
||||
- [[raw/official-docs/traefik-forwardauth-middleware-official]] — Traefik ForwardAuth middleware (K8s 환경 대안)
|
||||
- **검토한 대안**:
|
||||
- **대안 1: SPA Direct OIDC + Resource Server (P2A)** — 클라이언트가 직접 Keycloak 호출, 백엔드는 JWT validator. 장: 백엔드 stateless / 단: SPA에 token 노출. 비교 sub-branch: [[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]].
|
||||
- **대안 2: BFF (Backend-for-Frontend)** — 백엔드 session cookie + 백엔드가 token holder. 장: XSS surface 축소 / 단: 백엔드 stateful. (Curity / Philippe De Ryck 권고).
|
||||
- **대안 3: API Gateway 인증 (Kong, AWS API Gateway + Cognito)** — vendor lock-in + cloud 종속.
|
||||
- **대안 4: Service Mesh (Istio AuthorizationPolicy + JWT filter)** — K8s mesh 인프라 전제.
|
||||
- **대안 5: 백엔드 직접 인증 (Spring Security `oauth2Login`)** — 백엔드가 redirect/callback 처리. 단일 서비스에는 단순하나 다중 서비스 시 중복.
|
||||
- **비교 핵심**: Edge ForwardAuth는 **다중 백엔드 서비스가 동일 인증을 공유**할 때 가장 단순. 백엔드 코드 0줄 인증. 단, header spoofing 방어 (ingress-only traffic 강제 — K8s NetworkPolicy 또는 VPC SG) 필수. SPA Direct는 mobile/IoT까지 같은 token으로 쓸 때 유리. BFF는 XSS 민감 환경(예: 금융). Service Mesh는 이미 mesh 도입된 환경.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 본 sub-branch는 **문서까지만**. wiki 추출은 root branch의 비교 매트릭스 시점에 일괄 처리.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: 해당 없음 (문서 단계)
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목: 없음
|
||||
- `locally-verified` 항목: 없음
|
||||
- `prod-verified` 항목: 없음
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
- 본 sub-branch 전체가 `documented-only` 등급. 추후 root branch의 6 패턴 비교 매트릭스에 인용되는 형태로만 활용. `wiki/projects/` 직접 승급 없음.
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
---
|
||||
title: branch / feature-keycloak-federation-spa-zero-change (P2A → P2B 전환 시 SPA 코드 변경 없음 검증)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-CHILD-26742876
|
||||
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-federation-spa-zero-change
|
||||
parent_branch: feature-keycloak-patterns
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, spa, idp-brokering, google-federation, p2b]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: ad580e99833f810af4fd6be965901cc8462e0f8be77e4212fa2ff6fb00d2f59c
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-federation-spa-zero-change (P2A → P2B 전환 — SPA 코드 변경 없음 검증)
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/branch-notes/feature-keycloak-patterns]]의 branch-child.
|
||||
> 학습 노트. P2B는 `documented-only` 단계.
|
||||
|
||||
<!-- 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 변형이다 | SPA code change 없이 IdP brokering을 추가하는 cross-cutting 변형으로 분류한다 | [[raw/project-notes/keycloak-patterns-overview]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
| D1 | SPA는 idpHint 없이 Keycloak login surface를 사용한다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
| D2 | Google OAuth redirect target은 Keycloak broker endpoint로 둔다 | `local` | [[raw/project-notes/keycloak-patterns-overview]] | `proposed` |
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
없음.
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
**P2A 코드 그대로 두고 Keycloak에 Google IdP만 추가**해서 SPA가 변경 없이 Google 로그인 가능함을 실증한다. **IdP brokering의 핵심 가치**(= "SPA는 Keycloak만 안다") 검증.
|
||||
|
||||
면접 질문: "Google 로그인이 추가되면 SPA는 어디가 바뀌나요?"
|
||||
→ "거의 0입니다. Keycloak 로그인 화면에 'Sign in with Google' 버튼이 자동으로 노출되고, SPA가 받는 token은 여전히 Keycloak이 서명한 JWT입니다. `issuer`는 Keycloak, `aud`는 backend client id, `azp`는 SPA client id입니다. backend Resource Server는 Google이 추가됐다는 사실 자체를 모릅니다."
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- 전 단계 (P2A) 검증 환경 가정 — [[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]] 완료 상태
|
||||
- Keycloak admin에 Google Identity Provider 추가 절차
|
||||
- SPA 로그인 버튼 / `keycloak-js` 초기화 코드 변경 0 확인
|
||||
- Keycloak 로그인 화면이 "Sign in with Google" 버튼을 **자동으로** 노출하는지 확인
|
||||
- SPA가 받는 Keycloak token이 P2A와 **동일 구조** (`iss`, `aud`, `azp`) 확인
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- IdP Mappers 세부 — [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]]
|
||||
- 3-leg trust 검증 메커니즘 — [[raw/branch-notes/feature-keycloak-three-leg-trust-chain]]
|
||||
- Account Linking 정책 — [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]]
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 이 branch의 zero-change 검증·설정 결정의 **근거가 되는 외부 자료**. 같은 자료가 여러 결정의 근거면 여러 번 등장.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/keycloak-identity-brokering-overview-official]] | D1 — Keycloak이 외부 IdP(Google)로 인증을 위임, social login=federation (KC-IDP-BROKER-C1); broker endpoint URL 포맷은 `needs-confirmation` (KC-IDP-BROKER-C2) |
|
||||
| [[raw/official-docs/keycloak-google-idp-setup]] | D2 — Identity Providers → Add provider → Google 등록 절차 + Keycloak 표시 Redirect URI를 Google `Authorized redirect URIs`에 복사 (KC-GIDP-C1~C4), default scope `openid profile email` (KC-GIDP-C5) |
|
||||
| [[raw/official-docs/google-oauth2-redirect-uri-validation-official]] | D2 — Google redirect URI 검증 규칙: HTTPS 필수 / raw IP 금지 / exact match → `redirect_uri_mismatch` (GOOGLE-REDIR-C1~C3) |
|
||||
| [[raw/official-docs/keycloak-securing-apps-overview-official]] | D1 — SPA는 표준 OIDC flow로 통합, adapter는 last resort (KC-SECAPP-C2) → `keycloak.login()` 호출부가 IdP 종류와 무관하게 불변인 컨텍스트 |
|
||||
| [[raw/official-docs/keycloak-idp-hide-on-login-page-toggle-official]] | D1 — "Hide on Login Page" 토글은 ON일 때만 provider를 로그인 페이지에서 숨긴다(KC-HIDELOGIN-C3), IdP 구성 시 로그인 옵션으로 나타나는 것이 기본 서술(KC-HIDELOGIN-C2)이고 realm의 IdP는 기본적으로 모든 애플리케이션에 활성화됨(KC-HIDELOGIN-C1) → "Sign in with Google 버튼 자동 노출" 근거 보강. 단 Hide 토글의 정확한 기본값은 결합 추론이며 원문이 직접 진술하지 않음(KC-HIDELOGIN-C3 Does not prove) |
|
||||
| [[raw/official-docs/keycloak-idp-hint-client-suggested-official]] | D1 — **비교 대안(B)의 근거**: SPA가 특정 IdP를 강제하려면 `kc_idp_hint` 쿼리 파라미터(JS adapter는 `keycloak.createLoginUrl({ idpHint })`)가 필요함을 공식 확정 (KC-IDPHINT-C1, C3) — 이는 SPA 코드 변경에 해당하므로 zero-change 미채택. `keycloak.login({ idpHint })` 형태는 이 자료로 뒷받침되지 않음 (KC-IDPHINT-C3 Does not prove) |
|
||||
| [[raw/official-docs/keycloak-identity-provider-redirector-default-idp-official]] | D1 — **비교 대안(C)의 근거**: SPA 코드 변경 없이 realm-level 로 특정 IdP 를 강제하는 `Identity Provider Redirector` / `Default Identity Provider` (KC-IDPREDIR-C1~C4). 단 로그인 선택 화면 자체를 제거(KC-IDPREDIR-C3)하고 realm 공유 client lockout 위험(KC-IDPREDIR-C1+C3 에서의 추론; C5 는 post-login flow 필요를 말함)이 있어, "사용자가 버튼 클릭" 흐름을 검증하는 본 branch 는 미채택 |
|
||||
|
||||
> zero-change의 **기준선(baseline)**은 외부 자료가 아니라 형제 브랜치 [[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]] (P2A) 의 토큰 구조·backend JWT 검증 계약이다 — §구현 가이드·§엣지·실패·의존에서 dependency로 참조.
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] 전제 확인: P2A 검증 환경 (SPA + Resource Server + Keycloak realm) 동작 — 등급: `planned`
|
||||
- [ ] Google Cloud Console에서 OAuth 2.0 Client ID 생성 (redirect URI = `https://kc.example.com/realms/{r}/broker/google/endpoint`) — 등급: `documented-only`
|
||||
- [ ] Keycloak admin → Identity Providers → "Add provider" → Google 선택 → client_id / client_secret 입력 — 등급: `documented-only`
|
||||
- [ ] Keycloak 로그인 페이지 새로고침 → "Sign in with Google" 버튼 자동 노출 확인 — 등급: `documented-only`
|
||||
- [ ] SPA 코드 (`keycloak-js` init, login button) **git diff = 0** 확인 — 등급: `documented-only`
|
||||
- [ ] Google 로그인 성공 후 SPA가 받는 access_token decode → `iss=https://kc.example.com/realms/{r}`, `aud=<backend-client-id>`, `azp=<spa-client-id>` 확인 — 등급: `documented-only`
|
||||
- [ ] backend Resource Server JWT validation 코드 **git diff = 0** 확인 — 등급: `documented-only`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- "SPA 코드 변경 없음"은 **로그인 버튼 라벨**도 안 바뀐다는 뜻. Keycloak 로그인 화면이 "Sign in with Google" 버튼을 제공하므로 SPA는 그저 `keycloak.login()`을 호출할 뿐.
|
||||
- 만약 SPA가 자체 로그인 화면을 그리고 "Google로 로그인" 버튼을 직접 제공하려면 `keycloak.login({ idpHint: 'google' })`로 IdP를 강제할 수는 있음. 이건 코드 변경에 해당. 본 sub-branch는 **그것조차 안 한 경우**를 검증.
|
||||
- access_token의 `iss`가 Keycloak이라는 사실이 **brokering의 본질**. Google ID token은 Keycloak 내부에서 소비되고 폐기됨 (또는 broker endpoint에 저장되지만 SPA가 받는 token에는 없음).
|
||||
- 결과적으로 backend의 JWKS / issuer / audience validation 로직은 **P2A와 byte-for-byte 동일**.
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- 2026-05-25: SPA가 **`idpHint`를 사용하지 않음.** 이유: brokering 가치 검증이 목적이므로 Keycloak 기본 로그인 화면이 IdP 선택을 노출하는 표준 흐름을 사용.
|
||||
- 2026-05-25: Google Cloud OAuth Client는 **Web application** 타입 + redirect URI는 Keycloak broker endpoint 하나만 등록. SPA URL은 등록하지 않음 (SPA는 Google과 직접 통신하지 않음).
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시.
|
||||
> `선택 조건` 열(R2): "이 조건일 때 이 결정, 다른 조건이면 어떤 대안". 분기 없으면 `N/A`.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | SPA 는 `idpHint` 미사용 — Keycloak 기본 로그인 화면이 등록된 IdP(Google)를 버튼으로 자동 노출하는 표준 흐름 사용 | brokering 가치(= SPA는 Keycloak만 안다) 검증이 목표 → SPA는 `keycloak.login()`만 호출, IdP 선택은 Keycloak 로그인 화면에 위임(**zero-change**). **⟨대안 B⟩** SPA가 특정 IdP를 강제하려면 → `kc_idp_hint` 쿼리 파라미터(JS adapter 공식 예제는 `keycloak.createLoginUrl({ idpHint: 'google' })`; KC-IDPHINT-C1/C3) = **SPA 코드 변경**이므로 본 branch 범위 밖. **⟨대안 C⟩** realm-level `Identity Provider Redirector`의 Default Identity Provider(KC-IDPREDIR-C1/C2)로도 SPA 무관하게 강제 가능하나 **로그인 선택 화면 자체를 제거**(KC-IDPREDIR-C3) → 본 branch가 검증하려는 "사용자가 버튼 클릭" 흐름과 배치되어 미채택 | `raw/official-docs/keycloak-identity-brokering-overview-official.md#KC-IDP-BROKER-C1`, `raw/official-docs/keycloak-google-idp-setup.md#KC-GIDP-C1`, `raw/official-docs/keycloak-securing-apps-overview-official.md#KC-SECAPP-C2`, `raw/official-docs/keycloak-idp-hide-on-login-page-toggle-official.md#KC-HIDELOGIN-C1`, `raw/official-docs/keycloak-idp-hide-on-login-page-toggle-official.md#KC-HIDELOGIN-C2`, `raw/official-docs/keycloak-idp-hide-on-login-page-toggle-official.md#KC-HIDELOGIN-C3`, `raw/official-docs/keycloak-idp-hint-client-suggested-official.md#KC-IDPHINT-C1`, `raw/official-docs/keycloak-idp-hint-client-suggested-official.md#KC-IDPHINT-C3` | `official-vendor-doc` | 대안 B(idpHint)는 이제 공식 인용 확보(KC-IDPHINT) — 종전 "인용 미확보" 위험 해소. 잔여 위험: **(1)** `Hide on Login Page` 토글의 **신규 IdP 생성 시 기본값(ON/OFF)** 을 원문이 직접 진술하지 않음 — KC-HIDELOGIN-C1(realm 기본 활성화)+C2(구성 시 로그인 옵션으로 나타남)의 결합 추론이며 C3은 ON 동작만 확정 → Admin UI 실측 필요(§Claims To Verify 1행). **(2)** note §목표·§진행 중 메모가 가정한 `keycloak.login({ idpHint })` 형태는 공식 예제(`createLoginUrl`)로 뒷받침되지 않음(KC-IDPHINT-C3 Does not prove) — keycloak-js adapter 레퍼런스 별도 확인. **(3)** 대안 C(realm-level Default IdP)는 같은 realm 공유 client(Admin Console 포함) lockout 위험 — 이는 KC-IDPREDIR-C1(로그인 폼 대신 IdP redirect)+C3(default IdP 못 찾으면 폼 표시)에서의 **추론**이며, KC-IDPREDIR-C5(NOTE: IdP 로그인 후 browser flow 미계속 → post-login flow 필요)는 lockout 을 직접 진술하지 않음. 본 branch 미채택, 참고만 |
|
||||
| D2 | Google Cloud OAuth Client = Web application 타입, redirect URI = Keycloak broker endpoint 1개만 등록 (SPA URL 미등록) | SPA가 Google과 직접 통신하지 않고 Keycloak이 server-side broker → Google이 로그인 후 redirect하는 목적지는 Keycloak broker endpoint 뿐 → Web application 타입 + Keycloak이 표시하는 Redirect URI 1개만 등록. 반대로 SPA가 Keycloak을 우회해 Google에 **직접** OIDC를 하는 대안이면 SPA origin을 Google에 등록해야 하나, 그건 brokering 포기(= 본 패턴 아님) | `raw/official-docs/keycloak-google-idp-setup.md#KC-GIDP-C3`, `raw/official-docs/keycloak-google-idp-setup.md#KC-GIDP-C4`, `raw/official-docs/google-oauth2-redirect-uri-validation-official.md#GOOGLE-REDIR-C1`, `raw/official-docs/google-oauth2-redirect-uri-validation-official.md#GOOGLE-REDIR-C2`, `raw/official-docs/google-oauth2-redirect-uri-validation-official.md#GOOGLE-REDIR-C3`, `raw/official-docs/keycloak-identity-brokering-overview-official.md#KC-IDP-BROKER-C2` | `official-vendor-doc + needs-confirmation` | broker endpoint URL 포맷 `/realms/{realm}/broker/{provider}/endpoint`의 verbatim 부재 (KC-IDP-BROKER-C2 = `needs-confirmation`; KC-GIDP-C3도 정확한 path 형식은 명시 없음 → Admin UI 자동 표시값을 신뢰원으로 사용) → 실 Admin UI 표시값 캡쳐로 확정 필요. "Web application" 클라이언트 타입 명칭은 Google Cloud Console UI 관행 — 위 인용은 타입명 자체를 verbatim 보장하지 않음 (§구현 가이드 `UNSUPPORTED_IMPL_DECISION`) |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 본 branch는 `documented-only` 학습 노트 — "구현"은 **① Keycloak/Google 설정 절차 + ② zero-change 검증 방법**의 사전 명세다. 각 sub-section은 §Decision Evidence Map의 `Decision ID` + `Supporting Claim ID`로 trace. 실제 코드 변경이 없는 검증이므로 대부분 `planned`/방법 명세이며, P2A 실 구현에 종속되는 detail은 그 종속을 명시한다.
|
||||
|
||||
### 1. Google IdP 등록 절차 (양방향 등록)
|
||||
|
||||
> **Trace**: D2 (KC-GIDP-C1~C4, GOOGLE-REDIR-C1~C3) + D1 진입점(KC-IDP-BROKER-C1). Google ↔ Keycloak 양쪽 등록이 서로의 입력.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: "Web application" 클라이언트 타입 선택 — KC-GIDP/GOOGLE-REDIR 인용은 이 타입 명칭을 verbatim 보장하지 않음. trade-off: Keycloak broker는 client_secret을 보관하는 server-side confidential client이므로, Google Console의 SPA/Desktop/Mobile 타입이 아니라 **Web application** 타입이 관행(secret 발급 + redirect URI 등록이 가능한 유일 타입).
|
||||
|
||||
| # | 위치 | 작업 | 근거 |
|
||||
|---|------|------|------|
|
||||
| 1 | Google Cloud Console | OAuth 2.0 Client ID 생성, 타입 = **Web application** | KC-GIDP-C2 (Google에서 Client ID/Secret 발급) + UNSUPPORTED(타입 명칭) |
|
||||
| 2 | Google Console `Authorized redirect URIs` | Keycloak broker endpoint 1개만 등록: `https://<kc-host>/realms/<realm>/broker/google/endpoint` — **HTTPS 필수 · raw IP 금지 · exact match** | GOOGLE-REDIR-C1(HTTPS), C2(no raw IP), C3(exact match → `redirect_uri_mismatch`) |
|
||||
| 3 | Keycloak Admin → Identity Providers | `Add provider` 드롭다운 → **Google** 선택 → Client ID / Client Secret 입력 | KC-GIDP-C1, KC-GIDP-C2 |
|
||||
| 4 | Keycloak `Add Identity Provider` 페이지 | 페이지가 표시하는 **Redirect URI** 값을 복사 → 위 #2의 Google `Authorized redirect URIs`에 붙여넣기 (양방향 일치) | KC-GIDP-C3, KC-GIDP-C4 |
|
||||
| 5 | (검증 anchor) | broker endpoint URL의 정확한 path는 Admin UI 표시값을 신뢰(코드/인용상 verbatim 부재) | KC-IDP-BROKER-C2 (`needs-confirmation`) |
|
||||
|
||||
### 2. backend zero-change 검증 방법
|
||||
|
||||
> **Trace**: D1 (KC-IDP-BROKER-C1, KC-SECAPP-C2) + §목표(git diff = 0). 검증 대상은 "코드가 안 바뀐다"는 사실이므로 산출물은 diff 명령 결과와 token decode 대조표.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 검증 대상 파일의 정확한 경로·`keycloak-js` 버전은 P2A 실 구현에 종속 — P2A가 아직 `documented-only`이므로 경로를 확정할 수 없음(`planned`). trade-off: 파일 경로 대신 "IdP 종류와 무관한 호출부"(로그인 트리거·JWKS/issuer/audience validator)를 대상으로 정의.
|
||||
|
||||
| 검증 항목 | 방법 | 기대 결과 | 근거 |
|
||||
|---|---|---|---|
|
||||
| SPA 로그인 진입부 불변 | `keycloak-js` init + login 트리거 파일 `git diff` (P2A 대비) | diff = 0 (idpHint 미사용 → `keycloak.login()` 인자 불변) | D1; KC-SECAPP-C2(표준 flow) |
|
||||
| backend JWT 검증 불변 | Resource Server validator 코드 `git diff` | diff = 0 (backend는 Google 추가를 모름) | [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1 — backend validation/audience policy |
|
||||
| token 구조 동일 | Google 로그인으로 받은 access_token decode → P2A 로컬 로그인 token과 claim 대조 | `iss`=`https://<kc>/realms/<realm>`, `aud`=`backend-client-id`, `azp`=`spa-client-id` → **구조 동일** | [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D4 — audience owner; D1 |
|
||||
|
||||
> **주의(§엣지에서 상술)**: 구조(`iss`/`aud`/`azp`)는 동일하나 IdP Mapper가 role/group claim을 추가하면 **payload claim set은 커질 수 있음** — "byte-for-byte 동일"은 mapper 미적용 전제. Mapper 영향은 [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]]로 위임.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4 캡처용. zero-change 검증 중 실제로 부딪힐 실패/엣지 + 다른 branch 계약 의존.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **`redirect_uri_mismatch`**: Keycloak broker endpoint URL과 Google에 등록한 URI가 trailing slash/host/port까지 정확히 일치하지 않으면 Google이 거부. `KC_HOSTNAME` 오설정 시 Keycloak이 표시하는 endpoint URL이 어긋나 발생. 기대 동작: 로그인 실패 + Google `redirect_uri_mismatch`. (GOOGLE-REDIR-C3)
|
||||
- **"Sign in with Google" 버튼 미노출**: Google IdP는 등록됐으나 로그인 화면에 버튼이 안 뜨는 경우 — realm mismatch 또는 IdP의 "Hide on Login Page" 옵션이 ON(공식 근거 확보: `raw/official-docs/keycloak-idp-hide-on-login-page-toggle-official.md#KC-HIDELOGIN-C3` — ON일 때만 미노출). 단, 신규 IdP 등록 시 이 토글의 **기본 상태**는 원문이 직접 진술하지 않아(KC-HIDELOGIN-C3 Does not prove) 여전히 `needs-confirmation`. 이 경우 zero-change 전제(=화면이 버튼을 자동 제공)가 붕괴 → visual verify 필수(§Claims To Verify 1행).
|
||||
- **token claim set 확대**: 구조(`iss`/`aud`/`azp`)는 불변이나, IdP Mapper로 role/group을 주입하면 access_token payload가 P2A보다 커짐. "byte-for-byte 동일"은 **mapper 미적용 전제**에서만 성립. 기대 동작: 구조는 검증 통과하되 claim set 차이는 별도 인지.
|
||||
- **First Broker Login 충돌**: 같은 email의 기존 local user가 있으면 자동 link/충돌 분기 발생 — **본 branch 범위 밖**(zero-change 검증에 영향은 없으나 로그인 자체가 막힐 수 있음). 위임: [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]].
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]] (P2A) 의 baseline 환경(SPA public client + PKCE S256 + Resource Server) 완료 **전제**. Backend 기준선은 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1 — backend validation/audience policy를 직접 따른다. 이 owner 계약이 바뀌면 "zero-change" 기준선 자체가 바뀐다.
|
||||
- [[raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation]] (P2B, parent) 의 Google IdP 등록·Mapper·First Broker Login 상위 결정 — 특히 P2B `D6`(claim mapping)·`D7`(backend Keycloak-only 검증 / 3-leg trust)·`D1`/`D2`(First Broker Login 정책) — 에 의존. 본 branch는 그중 "SPA/backend 코드 변경 0" 축만 검증(나머지는 sibling으로 위임).
|
||||
- **(2026-07-17 갱신)** 위 P2B `D1`·`D2`·`D6`·`D7` 은 주제는 그대로이나 **P2B 가 더 이상 직접 소유하지 않는다** — P2B 는 구성 허브로 정리되며 각각 [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D4·D2, [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D4, [[raw/branch-notes/feature-keycloak-three-leg-trust-chain]] D1 로 위임됐다. 정책 **권위는 owner 노트**에 있으므로, 본 branch 의 전제가 바뀌었는지 확인할 때는 P2B 가 아니라 owner 를 본다.
|
||||
- [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]] — claim mapping이 token claim set에 미치는 영향(위 엣지 3행) 소유.
|
||||
- [[raw/branch-notes/feature-keycloak-three-leg-trust-chain]] — Browser↔Keycloak↔Google 3-leg 검증 메커니즘 소유.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| Keycloak 로그인 화면이 "Sign in with Google" 버튼을 자동으로 노출 (별도 SPA 코드 변경 없이) | raw 인용 KC-IDP-BROKER-C1 은 "delegate authentication" 까지만 보장. KC-HIDELOGIN-C1(realm 기본 활성화)+KC-HIDELOGIN-C2(구성 시 로그인 옵션으로 나타남)+KC-HIDELOGIN-C3(Hide 토글 ON일 때만 미노출)로 정황 근거는 보강됐으나, Hide 토글의 **신규 IdP 생성 시 기본값**은 원문이 직접 진술하지 않아(결합 추론) 실제 로그인 화면 UI 자동 노출은 여전히 별도 검증 필요 | P2A 환경에 Google IdP 추가 후 로그인 페이지 새로고침 → 버튼 노출 visual verify | `documented-only` |
|
||||
| SPA 가 받는 access_token 의 `iss=https://kc.example.com/realms/{r}`, `aud=<backend-client-id>`, `azp=<spa-client-id>` 구조가 P2A와 동일 | 정확한 audience provisioning은 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D4 소관이며 아직 runtime token을 발급하지 않음 | Google 로그인 성공 후 JWT decode + P2A 토큰과 claim-by-claim diff | `needs-confirmation` |
|
||||
| backend Resource Server JWT validation 코드 git diff = 0 | 추론 (brokering 의 본질) — verbatim 보장 부재 | git diff 명령 실행 후 결과 확인 | `documented-only` |
|
||||
| `keycloak-js` init / login button SPA 코드 git diff = 0 | 추론 — verbatim 보장 부재 | git diff 명령 실행 후 결과 확인 | `documented-only` |
|
||||
| Google ID token 이 Keycloak 내부에서 소비되고 SPA 에 노출되지 않음 | KC-IDP-BROKER-C1 의 "delegate" 만 보장, token 격리는 별도 | network tap 또는 SPA 측 token 검사 | `needs-confirmation` |
|
||||
| Keycloak broker endpoint URL 포맷 (`/realms/{realm}/broker/{provider}/endpoint`) 정확 | `KC-IDP-BROKER-C2` 자체가 `needs-confirmation` (Admin UI 관행, verbatim 부재) | Keycloak Admin UI → Identity Provider → Redirect URI 표시값 직접 캡쳐 | `needs-confirmation` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- (학습 단계, 미실행)
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/keycloak-identity-provider-redirector-default-idp-official]]
|
||||
- [[raw/official-docs/keycloak-idp-hide-on-login-page-toggle-official]]
|
||||
- [[raw/official-docs/keycloak-idp-hint-client-suggested-official]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
> 본 sub-sub-branch 는 leaf — 자식 자료 없음. Phase 3 P3A 실 구현 또는 외부 산출물 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
> 근거 외부 자료(official-docs)는 상단 `## Sources / 근거` 표에서 관리 — Cluster 에는 중복 나열하지 않는다.
|
||||
|
||||
### 오류 기록 (이 sub-sub-branch 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 documented-only 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — Phase 3 실 구현 단계에 누적)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 학습 노트. P2B는 `documented-only` 유지. 실제 brokering 검증 환경 구축은 별도 마일스톤.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: 학습 노트 (`documented-only`)
|
||||
- **wiki 추출 대상**:
|
||||
- `actually-implemented` 항목: (없음)
|
||||
- `locally-verified` 항목: (없음)
|
||||
- `prod-verified` 항목: (없음)
|
||||
- **추출하지 않을 항목**: 전 항목 (`documented-only`)
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
---
|
||||
title: branch / feature-keycloak-first-broker-login-flow (First Broker Login Flow — Confirm Link Existing Account)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-PATTERNS-OVERVIEW-016
|
||||
kind: project-work-item
|
||||
project: keycloak-patterns-overview
|
||||
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-016
|
||||
inherits: [DEC-KEYCLOAK-PATTERNS-OVERVIEW-IDP-BROKERING-001@1, DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-KEYCLOAK-PATTERNS-OVERVIEW-015]
|
||||
contract_packet: 1
|
||||
branch: feature-keycloak-first-broker-login-flow
|
||||
parent_branch:
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, p1b, first-broker-login, account-linking, security, account-takeover, email-verified]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 342adc2532438c3f05c868020a6cc7fdfc2a3322fddceed4180f75fd258fd6c0
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-first-broker-login-flow (First Broker Login Flow — Confirm Link Existing Account)
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/project-notes/keycloak-patterns-overview]]의 `WI-KEYCLOAK-PATTERNS-OVERVIEW-016` 직접 branch.
|
||||
> **분류축 교정 반영 (hub 2026-07-14)**: 신 primary 축에서 본 branch 는 **Google IdP brokering cross-cutting 그룹 (hub §8.0 그룹 5)** 의 Tier-2 구현 branch다.
|
||||
> **done-bar (hub §1 성공기준 cross / §8.0 그룹 5)**: `email_verified=false` auto-linking 계정탈취 **재현 → Confirm Link Existing Account 로 차단**, before/after 기록, 목표 등급 `locally-verified`. (기존 "documented-only / 실 구현 안 함" 프레이밍은 2026-07-14 재편으로 stale — §Audit `FRAMING_DRIFT` 참조.)
|
||||
> `status_label`: `in-progress`
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/project-notes/keycloak-patterns-overview]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: unsafe auto-linking을 재현하고 Confirm Link Existing Account로 차단한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-IDP-BROKERING-001@1` | Google federation은 Keycloak IdP brokering과 hardened First Broker Login을 사용한다 | First Broker Login의 계정 연결 방어 흐름에 적용한다 | [[raw/project-notes/keycloak-patterns-overview]] |
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1` | done-bar는 E2E success와 signature security failure 재현·해결 evidence다 | unsafe auto-linking의 재현과 차단 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 -->
|
||||
## 목표
|
||||
|
||||
Keycloak의 기본 First Broker Login Flow는 "Automatically Link Existing Account by Email" 옵션을 포함한다. 이는 **편의성을 위해 보안을 희생**한 default이며, Google이 `email_verified=false`인 사용자도 발급할 수 있는 상황을 고려하면 account takeover 위험이 있다.
|
||||
|
||||
본 노트는 **"Confirm Link Existing Account"** 변형 flow로 변경하는 방법과 그 의미를 정리한다.
|
||||
|
||||
> ⚠️ **2026-07-16 조사 정정 (위 전제 수정)**: "기본 flow 가 auto-link 를 *포함*한다"는 부정확하다. OOTB Default First Broker Login flow 는 `Create User If Unique`(충돌 감지 key = **email/username**, `KC-FBLVERIFY-C5`) → `Handle Existing Account`(= Confirm Link Existing Account + Verify Existing Account) 경로가 **이미 기본값**이고, email 로 *자동* link 하는 `Automatically Set Existing User`(AutoLink) 는 별도로 추가해야 하는 opt-in dangerous authenticator 다(공식 WARNING `KC-FBLVERIFY-C4`). 따라서 본 branch 의 done-bar 는 "기본에서 auto-link 를 *제거*"가 아니라 **함정을 *재현*하려면 AutoLink 를 명시 추가한 뒤, 기본 Confirm Link 로 되돌려 차단**하는 것이다(§구현 가이드, §Audit `CLAIM_DRIFT`). 원문은 verbatim 보존.
|
||||
|
||||
**위험 시나리오 (Auto Link 사용 시):**
|
||||
1. 공격자가 자신의 Google 계정 email을 `victim@example.com`으로 위장 (Google이 `email_verified=false`로 발급)
|
||||
2. Keycloak에 이미 `victim@example.com`으로 가입된 local 계정 존재
|
||||
3. Auto Link가 email match만 보고 두 계정을 link → 공격자가 Google 로그인으로 피해자 계정 접근
|
||||
|
||||
**해결:** "Confirm Link Existing Account" flow는 link 전에 **사용자가 기존 Keycloak 계정 password를 입력**(또는 email 확인)해야 하므로, Google 계정만으로는 link 불가.
|
||||
|
||||
- 이슈: (없음 — 학습 프로젝트)
|
||||
- PR: (없음 — 코드 repo `/home/donghyeon/workspace/keycloak-patterns/` 아직 미생성, §Audit `NO_GROUND_TRUTH`)
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- **First Broker Login Flow 의 *구성* (authenticator step 값)** — 이 branch 가 owner (sibling [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] 이 flow 구성을 본 branch 로 위임):
|
||||
- D1: `Automatically Set Existing User`(AutoLink) **미사용/DISABLED**, 기본 Confirm Link 경로 유지.
|
||||
- D2: `Confirm Link Existing Account` + `Verify Existing Account`(Email 기본 / Re-authentication fallback) 강제.
|
||||
- D3: `Review Profile` 모드 결정 (Off 권장).
|
||||
- D4: `email_verified=false` silent auto-link 차단 (= core D1+D2). [[raw/branch-notes/feature-keycloak-idp-brokering-google-client]] D6 (`trustEmail=false`)는 defense-in-depth로만 consume.
|
||||
- **함정 재현 → 차단 E2E 절차** (done-bar): AutoLink 로 계정탈취 재현 → Confirm Link 로 차단, before/after 기록.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- **Account linking primary key (`sub` vs email) 선택** → [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] D1 (owner).
|
||||
- **`trustEmail` IdP 설정값 / Google client 등록 / discovery** → [[raw/branch-notes/feature-keycloak-idp-brokering-google-client]] (owner; D6 = `trustEmail=false`).
|
||||
- **Google claim → attribute mapper 구성 / Sync Mode 값** → [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]].
|
||||
- **SPA 측 Confirm Link redirect/return UX** → [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]].
|
||||
- **`sub` 기반 충돌 감지 전용 custom authenticator** — OOTB `Create User If Unique` 는 email/username 매칭(`KC-FBLVERIFY-C5`). sub-only 매칭·`email_verified` hard-reject 는 커스텀 SPI authenticator 필요 → **hub §5 Deferred(server-side SPI 트랙)** (§Audit `OUT_OF_BRANCH_SCOPE`).
|
||||
- 비-Google IdP / SAML federation.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/keycloak-first-login-flow]] | D1(auto-link = "potential security hole" 공식 경고 `KC-FLF-C2`), D2(Confirm Link info page review/link 선택 `KC-FLF-C3`), D3(Review Profile 3모드 On/missing/Off `KC-FLF-C4`) |
|
||||
| [[raw/official-docs/keycloak-first-broker-login-verify-authenticators-official]] | D1(AutoLink 공식 WARNING `KC-FBLVERIFY-C4` + OOTB 충돌감지 key=email/username `KC-FBLVERIFY-C5`), D2(Verify Existing Account By Email = SMTP 시 `ALTERNATIVE` 기본 `KC-FBLVERIFY-C1`, 재인증 관철=email DISABLE `KC-FBLVERIFY-C2`, Re-auth=fallback `KC-FBLVERIFY-C3`) |
|
||||
| [[raw/official-docs/google-openid-connect-oidc]] | D3(`email` claim 은 `email` scope 시 제공 `GOIDC-C4`); D4 전제(`email_verified` 발급 조건은 본 인용 **범위 밖** → Claims To Verify) |
|
||||
| [[raw/company-tech-blogs/keycloak-google-login-codemancers]] | D2 corroboration — 실무 Confirm flow 적용 사례 (`company-case-study`, **공식 best practice 로 단정 금지**) |
|
||||
| [[raw/official-docs/keycloak-identity-brokering-overview-official]] | First Login Flow Override(IdP 별 flow 지정) 메커니즘 배경 |
|
||||
| (위임) [[raw/branch-notes/feature-keycloak-idp-brokering-google-client]] `D6` | `trustEmail=false` — D4 email_verified 방어의 IdP 설정 lever (본 branch 재진술 금지, 참조만) |
|
||||
| (위임) [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] `D1` | `sub` 기반 linking key — 본 flow 가 정합해야 할 linking 정책 |
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급. 본 branch 는 hub §8.0 그룹 5 의 Tier-2 구현 branch (목표 `locally-verified`) — 코드 repo 생성 전까지 대부분 `planned`.
|
||||
|
||||
- [ ] First Broker Login Flow 기본 구조 정리 — 등급: `documented-only`
|
||||
- `Review Profile` (신규 사용자 프로필 확인 페이지)
|
||||
- `Create User If Unique` (federated identity 없으면 신규 user 생성)
|
||||
- `Automatically Link Existing Account` (기본 옵션 — 본 노트가 제거 대상)
|
||||
- `Handle Existing Account` (수동 confirm 서브플로우)
|
||||
- [ ] Authentication → Flows → "First Broker Login" 복제 — 등급: `planned`
|
||||
- 기본 flow는 read-only → `Copy`로 사본 생성 후 편집
|
||||
- [ ] "Automatically Link Existing Account" 단계 제거 또는 `DISABLED` — 등급: `planned`
|
||||
- 해당 step의 requirement를 `DISABLED`로 설정
|
||||
- [ ] "Confirm Link Existing Account" 단계 `REQUIRED` 활성화 — 등급: `planned`
|
||||
- 사용자에게 기존 계정 link 여부 확인 페이지 표시
|
||||
- 이후 `Verify Existing Account by Re-authentication` step에서 password 입력
|
||||
- [ ] Identity Provider 설정에서 변경된 flow를 `First Login Flow Override`로 지정 — 등급: `planned`
|
||||
- [ ] Review Profile flow 정책 결정 — 등급: `documented-only`
|
||||
- Google이 `email` / `name` / `picture` 제공 → 신규 사용자 확인 페이지 불필요한 경우 OFF
|
||||
- GDPR 등 동의 페이지 필요한 경우 ON
|
||||
- [ ] `email_verified=false` silent auto-link 차단 정책 — 등급: `documented-only`
|
||||
- 현재 채택 범위는 D1+D2의 소유 증명 없는 자동 link 차단까지다.
|
||||
- flow 진입 즉시 link/생성을 모두 거부하는 hard-reject는 구현된 custom SPI artifact가 없으므로 본 branch에서 보장하지 않는다(별도 SPI variant로 유보).
|
||||
|
||||
> ⚠️ **2026-07-16 조사 정정 (위 항목 전제 수정)**: (a) TODO 3 "기본에서 auto-link 제거" 는 부정확 — OOTB 기본은 이미 Confirm Link 이고 AutoLink 는 별도 opt-in(`KC-FBLVERIFY-C4/C5`). 재현 시 *추가* 후 *제거*로 재구성(§구현 가이드). (b) TODO 4 "Verify Existing Account by Re-authentication REQUIRED" 는 부정확 — SMTP 설정 realm 은 "Verify Existing Account By Email"(`ALTERNATIVE`)이 기본, 재인증 관철은 email authenticator 명시 DISABLE 필요(`KC-FBLVERIFY-C1/C2/C3`). 원 TODO 는 verbatim 보존, 정정은 §Decision Evidence Map Open Risk + §Audit 를 따른다.
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- Keycloak 공식 문서가 명시적으로 경고: **"automatic linking by email = potential security hole"** ([[raw/official-docs/keycloak-first-login-flow]] `KC-FLF-C2`); AutoLink authenticator 별도 WARNING ([[raw/official-docs/keycloak-first-broker-login-verify-authenticators-official]] `KC-FBLVERIFY-C4`).
|
||||
- "Confirm Link Existing Account" flow는 사용자 UX에 한 단계 추가됨 (link 확인 페이지 + password 재입력 또는 email 확인) — 보안 trade-off로 수용.
|
||||
- 신규 사용자 (기존 Keycloak 계정 없음) 흐름은 변경 없음: `Create User If Unique` → 신규 user 생성 → (선택) Review Profile.
|
||||
- 본 flow 변경은 **Google IdP에만 적용** 가능 (IdP별 First Login Flow Override 지원). 다른 IdP에 다른 flow 적용 가능.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
> 추후 면접/회고에서 "왜 이렇게 했나" 답할 근거. **원 결정문 verbatim 보존** — 조사 정정은 §Decision Evidence Map Open Risk + §Audit & Findings (CLAUDE.md §11: 사용자 결정 자동 rewrite 금지, 정합 권고만).
|
||||
|
||||
- 2026-05-25: 기본 First Broker Login Flow의 `Automatically Link Existing Account` step을 비활성화 (`DISABLED`). 보안 위험 회피.
|
||||
- 2026-05-25: `Confirm Link Existing Account` + `Verify Existing Account by Re-authentication` step을 `REQUIRED`로 활성화. 사용자가 기존 계정 password를 입력해야 link 완료.
|
||||
- 2026-05-25: Review Profile flow는 `OFF` 권장 (Google이 profile 제공). 단, 동의 페이지 비즈니스 요건 있을 시 `ON`.
|
||||
- 2026-05-25 (historical, superseded): ~~Google `email_verified=false`인 사용자는 link 시도 자체를 차단 (custom authenticator 또는 mapper로 강제 검증).~~
|
||||
- 2026-07-18: 현재 채택 정책은 **silent auto-link 방지**다. `email_verified=false` 전체 hard-reject는 custom SPI가 실제 구현·검증된 별도 variant에서만 활성화한다.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시한다.
|
||||
> `Decision ID` 는 이 branch-note 안에서 안정적으로 유지한다 — 형제 [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] 가 본 branch 의 `D1`/`D2`/`D4` 를 참조하므로 ID 불변.
|
||||
> `Supporting Claims` 는 `raw/<category>/<slug>.md#<CLAIM-ID>` 형식. `선택 조건`(R2): 이 조건일 때 이 결정 / 다른 조건이면 어떤 대안.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | `Automatically Set Existing User`(AutoLink) **미사용 / DISABLED** — 기본 `Handle Existing Account`(Confirm Link) 경로 유지. **정정**: OOTB 기본은 이미 Confirm Link, AutoLink 는 별도 opt-in dangerous authenticator | 운영 flow 는 항상 이 결정 — 사용자가 임의 username/email 로 자체 등록 가능한 환경에서 AutoLink 는 공식 위험(`KC-FBLVERIFY-C4`). 대안(AutoLink 사용)은 관리자가 등록을 엄격히 curating + username/email 을 배정하는 환경에서만. 함정 *재현* 시에만 AutoLink 명시 추가(§구현 가이드 §1) | `raw/official-docs/keycloak-first-login-flow.md#KC-FLF-C2` (auto-link = potential security hole), `raw/official-docs/keycloak-first-broker-login-verify-authenticators-official.md#KC-FBLVERIFY-C4` (AutoLink WARNING), `#KC-FBLVERIFY-C5` (OOTB 충돌감지 key = email/username) | `official-vendor-doc` | 재현용 AutoLink authenticator 의 정확한 명칭/추가 위치는 admin UI 확인 필요(Claims To Verify #1). 코드 repo 부재 → `planned` |
|
||||
| D2 | 기존 Keycloak local 계정에 Google federated identity link 시 → `Confirm Link Existing Account` + `Verify Existing Account` 강제. **정정**: SMTP 설정 realm 은 "Verify Existing Account By Email"(`ALTERNATIVE`)이 기본 — password 재인증을 관철하려면 admin 이 email authenticator 를 명시 DISABLE | security-first(secret 소유 증명 필요) → email authenticator DISABLE → Re-authentication. SMTP 미설정 realm → Re-authentication 자동 폴백. 소비자 서비스(마찰·지원부담 최소) → Email 검증 기본값 유지 가능(단 secret 미증명) | `raw/official-docs/keycloak-first-login-flow.md#KC-FLF-C3` (info page review vs link 선택), `raw/official-docs/keycloak-first-broker-login-verify-authenticators-official.md#KC-FBLVERIFY-C1` (Email=SMTP 시 기본), `#KC-FBLVERIFY-C2` (재인증 관철=email DISABLE), `#KC-FBLVERIFY-C3` (Re-auth=폴백); `raw/company-tech-blogs/keycloak-google-login-codemancers.md` (사례 corroboration) | `official-vendor-doc` (+ `company-case-study`) | Google-first 가입(비밀번호 미설정) 사용자는 Re-authentication 으로 재인증 수단 없어 lockout 가능 — 사용자 population 조사 필요(`needs-confirmation`) |
|
||||
| D3 | `Review Profile` = `Off` 권장 (Google 이 profile 제공). 비즈니스 동의 요건 시 `On` | Google 이 email/name 제공(profile scope) → `Off`. mandatory 정보(email/first/last name) 미제공 IdP → `missing`. GDPR 등 동의 페이지 필요 → `On` | `raw/official-docs/keycloak-first-login-flow.md#KC-FLF-C4` (3모드 On/missing/Off 정의), `raw/official-docs/google-openid-connect-oidc.md#GOIDC-C4` (`email` claim = `email` scope 시 제공) | `official-vendor-doc` | Google `profile` scope 가 first/last name 을 *항상* 채우는지는 GOIDC 인용 범위 밖 — dev 확인 필요 |
|
||||
| D4 | `email_verified=false` 계정의 **silent auto-link 차단** — **core = D1(AutoLink 미사용) + D2(Confirm Link 소유증명)** (이것만으로 성립), `trustEmail=false` 는 **defense-in-depth**(위임). 원 결정의 "전용 custom authenticator/mapper hard-reject" 는 현재 구현 artifact가 없어 `OUT_OF_BRANCH_SCOPE`(hub §5 Deferred) | core 는 항상 이 결정 — 공격자가 소유 증명 없이는 link 불가(Confirm Link). `email_verified=false` 를 flow 진입 즉시 *hard-reject* 하려면 구현·검증된 커스텀 SPI authenticator가 필요 → 별도 variant | `raw/official-docs/keycloak-first-login-flow.md#KC-FLF-C2`, `raw/official-docs/keycloak-first-broker-login-verify-authenticators-official.md#KC-FBLVERIFY-C4` (+ defense-in-depth 위임 [[raw/branch-notes/feature-keycloak-idp-brokering-google-client]] D6) | `official-vendor-doc` (core 조합) | **core 차단(D1+D2)은 `trustEmail`·`email_verified` 검증과 무관하게 성립** — fix E2E 는 D6 에 blocking 아님. `trustEmail=false` 값 선택은 owner D6에 공식 근거가 있으나 runtime은 `needs-confirmation`; 전제 "Google `email_verified=false` 발급"(Claims To Verify #4)도 부가 방어 강화용으로만 필요 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> *결정(Decisions)* 이 "*무엇*" 이라면 본 §는 "*어디에 어떻게*" 의 사전 명세 — done-bar(재현→차단 E2E)를 다음 구현자가 되묻지 않고 수행할 수준으로. anchor 는 **공식 문서가 규정하는 authenticator 명칭·트리거 조건**. 코드 repo(`/home/donghyeon/workspace/keycloak-patterns/`)가 아직 없으므로 구현 detail 은 `planned`, admin UI 로만 확인 가능한 값은 `UNSUPPORTED_IMPL_DECISION`.
|
||||
> 본 branch owned 구현 대상은 **flow *구성*(D1~D4)** 뿐. `trustEmail` 값·Google client 등록은 [[raw/branch-notes/feature-keycloak-idp-brokering-google-client]] 위임, linking key(sub)는 [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] 위임 — 본 §는 *정책 참조*만.
|
||||
|
||||
### 1. 함정 재현 (Reproduce) — email-match auto-linking 계정탈취
|
||||
|
||||
> **Trace**: done-bar(hub §8.0 그룹 5) + D1 + `KC-FBLVERIFY-C4`/`C5` + `KC-FLF-C2`.
|
||||
>
|
||||
> **재현의 정확한 벡터 (2026-07-16 depth-audit Finding 2 반영)**: AutoLink 는 **email/username *값* 매칭**으로 link 하며 `email_verified` 를 트리거로 보지 않는다(`KC-FBLVERIFY-C5`). 따라서 재현의 필수 조건은 "공격자 토큰의 `email` = victim 의 email" 이고, `email_verified=false` 는 *그 email 을 신뢰하면 안 되는 이유*(unverified 소유 주장)일 뿐 AutoLink 발동 조건이 아니다. `email_verified` 자동신뢰 벡터는 §2 의 `trustEmail` 방어(위임 D6) 관심사로 분리 — 즉 본 재현은 `email_verified` 미검증(Claims To Verify #4)에 **의존하지 않는다**.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION (재현 harness)**: 실제 Google 로는 *내가 소유하지 않은* `victim@example.com` 토큰을 발급받을 수 없어 real Google 로는 `before(탈취 성공)` 관측 불가. 재현은 **claim 을 제어 가능한 OIDC OP** 로 수행 — (a) 로컬 스택에 2번째 Keycloak realm 을 mock OP 로 세워 main realm 에 외부 OIDC IdP 로 등록하고 그 OP 사용자 `email=victim@example.com` 설정, 또는 (b) 경량 mock-oidc OP 로 임의 `email`/`email_verified` claim emit. trade-off: (a) Keycloak 자족(추가 realm 운영) vs (b) 경량(별도 컨테이너) — 착수 시 (a) 권장. AutoLink authenticator 의 정확한 UI 명칭("Automatically Set Existing User" vs "Automatically Link Existing Account")·requirement·위치도 admin UI 확정(Claims To Verify #1). 코드 repo 부재 → `planned`.
|
||||
|
||||
| 단계 | 명세 | 근거 |
|
||||
|---|---|---|
|
||||
| 전제(dep) | Google IdP 대신 **제어 가능한 OIDC OP**(2nd Keycloak realm 또는 mock OP)를 main realm 에 외부 IdP 로 등록 — 공격자가 `email` claim 을 통제해야 함 | done-bar, depth-audit Finding 1 |
|
||||
| 피해자 셋업 | main realm 에 local user `victim@example.com`(password 설정) 존재 | done-bar |
|
||||
| 취약 flow | First Broker Login 사본에 AutoLink(`Automatically Set Existing User`) authenticator 추가 → **email 값 매칭**으로 무확인 link | `KC-FBLVERIFY-C4` (WARNING), `KC-FBLVERIFY-C5` (email/username 매칭) |
|
||||
| 공격 | 제어 OP 사용자 `email=victim@example.com`(`email_verified=false` = 신뢰불가 email 표현이나 AutoLink 트리거 아님) → 그 IdP 로 로그인 → AutoLink 가 email 값으로 victim 계정에 연결 | `KC-FLF-C2` (위협), 목표/WHY 시나리오 |
|
||||
| 관측(before) | 공격자 세션이 victim 의 계정/roles 보유 — 탈취 성공 로그/스크린샷 | done-bar |
|
||||
|
||||
### 2. 차단 (Fix) — Confirm Link Existing Account
|
||||
|
||||
> **Trace**: D1 + D2 + `KC-FLF-C3` + `KC-FBLVERIFY-C1`/`C2`/`C3`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: Email vs Re-authentication 중 무엇을 강제할지의 realm-level 선택(SMTP 유무)은 배포 realm 사실 — 본 branch 는 *정책*(secret 증명 우선 시 email DISABLE)만. trade-off: SMTP 미설정 학습 realm 은 Re-auth 가 자동 폴백이므로 별도 조치 불필요.
|
||||
|
||||
| 단계 | 명세 | 근거 |
|
||||
|---|---|---|
|
||||
| flow 복원 | AutoLink 제거 → 기본 `Handle Existing Account`(= `Confirm Link Existing Account` + `Verify Existing Account`) 유지 | D1, `KC-FLF-C3` |
|
||||
| 재인증 관철(선택) | password 소유 증명 강제 시: "Verify Existing Account By Email" **DISABLE** → "Verify Existing Account By Re-authentication" 실행 | `KC-FBLVERIFY-C2`, `KC-FBLVERIFY-C3` |
|
||||
| 기본 동작 주의 | SMTP 설정 realm 은 email 확인이 기본(`ALTERNATIVE`) — secret 미증명이라도 소유 확인은 됨 | `KC-FBLVERIFY-C1` |
|
||||
| email_verified 보강 (defense-in-depth, 위임) | `trustEmail=false` 로 IdP email 을 무조건 verified 처리하지 않음. **단 core 차단(Confirm Link 소유증명)은 `trustEmail` 값과 무관하게 성립** — 본 fix E2E 는 D6 에 blocking 되지 않는 부가 방어 | 위임 [[raw/branch-notes/feature-keycloak-idp-brokering-google-client]] D6 (자체 `UNSUPPORTED` — 근거 확정은 그 branch) |
|
||||
| 재공격 | 공격자(제어 OP) 로그인 → Confirm Link info page → email 확인/password 재인증 요구 → 소유 증명 실패 → **link 차단** | D2, `KC-FLF-C3` |
|
||||
| 관측(after) | before(탈취 성공) vs after(차단) 대조 기록 → `locally-verified` 승급 근거 | done-bar |
|
||||
|
||||
### 3. Review Profile 모드 — D3
|
||||
|
||||
> **Trace**: D3 + `KC-FLF-C4`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: Google `profile` scope 가 first/last name 을 항상 채우는지 미확인(Claims To Verify #? — GOIDC 범위 밖). trade-off: 미충족 시 `missing` 모드가 안전(누락 시에만 프로필 페이지).
|
||||
|
||||
| 모드 | 조건 | 근거 |
|
||||
|---|---|---|
|
||||
| `Off` (권장) | Google 이 email/name 제공 → 프로필 페이지 불필요 | `KC-FLF-C4`, `GOIDC-C4` |
|
||||
| `missing` | mandatory(email/first/last name) 미제공 IdP → 누락 시만 표시 | `KC-FLF-C4` |
|
||||
| `On` | GDPR 등 동의/추가정보 수집 요건 | `KC-FLF-C4` |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4 캡처용. 정상 경로 외에 *구현 중 부딪힐* 실패/엣지/다른 계약 의존을 미리 열거.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **Google-first 가입 lockout (D2)**: 비밀번호 미설정(Google 으로만 가입) 사용자가 다른 IdP/계정 link 충돌 시 "Verify Existing Account By Re-authentication" 로 넣을 password 가 없어 진행 불가 — email 폴백 또는 대안 경로 설계 필요(`needs-confirmation`).
|
||||
- **SMTP 미설정 realm (D2)**: "Verify Existing Account By Email"(기본 `ALTERNATIVE`) 사용 불가 → 자동으로 "Verify Existing Account By Re-authentication" 폴백(`KC-FBLVERIFY-C3`). 학습 스택은 SMTP 없이 시작하므로 기본이 Re-auth 임에 유의.
|
||||
- **OOTB 충돌감지 = email/username (D1 tension)**: `Create User If Unique` 는 IdP `sub` 가 아니라 email/username 으로 충돌 감지(`KC-FBLVERIFY-C5`). sub-only 매칭·`email_verified` hard-reject 를 원하면 커스텀 SPI authenticator 필요 → 본 branch 범위 밖(§Audit `OUT_OF_BRANCH_SCOPE`, hub §5 Deferred).
|
||||
- **flow 오설정 시 Google IdP 전면 차단**: 기본 flow 는 read-only — 반드시 복제 후 편집. 잘못 편집하면 해당 IdP 로그인 전체가 막힘(진행 중 메모).
|
||||
- **재현용 취약 구성 잔존 위험**: 함정 재현(§1) 후 AutoLink authenticator 를 제거하지 않으면 실제 취약점이 남음 — 차단(§2) 단계에서 반드시 원복 확인.
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-keycloak-idp-brokering-google-client]] `D6`(`trustEmail=false`) + `D1`~`D5`(Google client 등록·discovery·redirect URI) — 본 flow 의 **전제**. IdP 미등록이면 First Broker Login 자체가 트리거되지 않음. `trustEmail` 값이 바뀌면 D4 방어 전제도 변함.
|
||||
- [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] `D1`(sub 기반 linking key) — 본 flow 는 sub-vs-email 의 linking 정책과 정합해야. 그 branch 는 flow *구성* 을 본 branch `D1`/`D2`/`D4` 에 위임(역방향 의존).
|
||||
- [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] `D3`(Sync Mode)/`D4`(email Attribute Importer) — first-login 시 매핑되는 attribute owner. 매핑이 바뀌면 §Review Profile 입력값도 바뀜.
|
||||
- [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]] — Confirm Link info page 노출 시 SPA redirect/return UX. 본 flow 가 확인 페이지를 트리거하면 그 branch 가 UX 를 consume.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서 근거가 있어도 내 프로젝트/버전에서의 동작을 자동으로 보장하지 않는다.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| OOTB Default First Broker Login flow 는 auto-link 를 *포함하지 않으며*(`Create User If Unique`→`Handle Existing Account`=Confirm Link 가 기본), 함정 재현엔 `Automatically Set Existing User`(AutoLink) authenticator 를 명시 추가해야 한다 — 그 정확한 명칭/requirement/추가 위치 | `KC-FBLVERIFY-C4/C5` 로 방향은 확정됐으나 배포 버전 admin UI 의 정확한 authenticator 라벨·토글은 미확인 (기존 노트의 "기본이 auto-link" 전제는 §Audit `CLAIM_DRIFT` 로 정정) | Keycloak admin console > Authentication > Flows > First Broker Login 복제 후 step 캡처 + AutoLink 추가 시연 | `needs-confirmation` |
|
||||
| `Confirm Link Existing Account` 와 `Verify Existing Account`(By Email / By Re-authentication) 의 requirement 조합이 admin UI 에서 의도대로 설정 가능 | `KC-FLF-C3`/`KC-FBLVERIFY-C1~C3` 는 트리거 조건만; 실제 UI step list·설정 가능성은 별도 | dev Keycloak flow editor 에서 step list + email authenticator DISABLE 시연 | `needs-confirmation` |
|
||||
| Identity Provider 의 "First Login Flow Override" 가 IdP 별로 다른 flow 지정 가능 | 본 branch Sources 에 Override 메커니즘 verbatim 없음(개요만) | Admin console > Identity Providers > Google > Advanced Settings 캡처 또는 admin guide 추가 인용 | `needs-confirmation` |
|
||||
| Google 이 일부 시나리오에서 `email_verified=false` ID token 발급 가능 (D4 전제) | `GOIDC-C4` 는 `email` claim 만; `email_verified` semantics 는 명시적 범위 밖 | `raw/official-docs/google-openid-connect-oidc.md` claims table 의 `email_verified` 행 추가 발췌 또는 dev Google 계정으로 재현 | `needs-confirmation` |
|
||||
| `email_verified=false` hard-reject 를 원하면 커스텀 SPI authenticator 가 필요하다 (D4 OUT_OF_BRANCH_SCOPE 근거) | OOTB 는 email/username 매칭(`KC-FBLVERIFY-C5`), `email_verified` 조건부 거부 built-in 여부 미확인 | Keycloak Identity Provider Mappers / First Broker Login SPI 문서 확인 + dev 재현 | `needs-confirmation` |
|
||||
| 인용한 authenticator 명칭·기본 등급(`ALTERNATIVE`)이 배포 예정 Keycloak **release tag** 에서도 동일 | 근거 raw(`first-login-flow.adoc`)는 keycloak `main` branch 기준(`KC-FBLVERIFY` 버전 caveat) | 배포 버전 tag 의 admin guide / Admin Console 재확인 | `needs-confirmation` |
|
||||
|
||||
## Audit & Findings
|
||||
|
||||
> 2026-07-16 `/branch-spec` 채움(기존 corpus 정독 — web 조사 불요, 모든 결정 근거는 이미 raw 에 존재) 결과. 사용자 작성 결정은 verbatim 보존, 아래는 정합 권고·정정만 (CLAUDE.md §11).
|
||||
|
||||
- **FRAMING_DRIFT (Should-fix, hub 정합)**: 노트 원 프레이밍 "P1B sub-sub-branch 는 전체 `documented-only` / 실 구현 안 함 / wiki 추출 안 함" 은 hub 2026-07-14 재편(§8.0 그룹 5 + §1 성공기준 + §10 Phase 2/3)으로 **stale**. 현재 이 branch 는 Google 그룹 Tier-2 **구현** branch(done-bar = 재현→차단, 목표 `locally-verified`). 헤더·범위·Closure 를 구현 branch 로 갱신, 원 결정문/TODO 는 verbatim 보존.
|
||||
- **CLAIM_DRIFT (D1, 정정)**: 원 전제 "기본 First Broker Login Flow 가 `Automatically Link Existing Account` 를 *포함*, 이를 *제거*" 는 부정확. OOTB 기본은 `Create User If Unique`(충돌감지 key = **email/username** `KC-FBLVERIFY-C5`) → `Handle Existing Account`(Confirm Link) 이고, email *자동* link 는 별도 opt-in `Automatically Set Existing User`(공식 WARNING `KC-FBLVERIFY-C4`). done-bar 는 "제거"가 아니라 "재현 위해 *추가* → 기본으로 *복원*"(§구현 가이드 §1→§2).
|
||||
- **CORRECTION (D2, 정정)**: 원 "Verify Existing Account by Re-authentication `REQUIRED`(기본)" 은 부정확 — SMTP 설정 realm 은 "Verify Existing Account By Email"(`ALTERNATIVE`)이 기본(`KC-FBLVERIFY-C1`). password 재인증 관철엔 admin 이 email authenticator 를 **명시 DISABLE** 필요(`KC-FBLVERIFY-C2`); Re-auth 는 email 사용 불가 시 폴백(`KC-FBLVERIFY-C3`). (동일 정정이 형제 [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] §Audit `CORRECTION(D2)` 에도 존재 — 본 branch 가 flow *구성* owner 이므로 정정의 canonical 위치는 여기.)
|
||||
- **RESCOPE (D4, UNSUPPORTED → 조합 근거 + OUT_OF_BRANCH_SCOPE)**: 원 D4 "custom authenticator/mapper 로 `email_verified=false` 강제 차단" 은 그 자체로 근거 부재(`UNSUPPORTED_DECISION`)였다. 실무 방어는 **D1(AutoLink 미사용) + D2(Confirm Link 소유증명) + 위임 `trustEmail=false`(idp-brokering-google-client D6)** 의 *조합*으로 이미 성립(공격자가 소유 증명 없이 link 불가) → 조합 근거로 grounded. flow 진입 즉시 *hard-reject* 하는 전용 custom SPI authenticator 는 **hub §5 Deferred(server-side SPI 트랙)** 으로 `OUT_OF_BRANCH_SCOPE`. 전제(Google `email_verified=false` 발급)는 Claims To Verify #4.
|
||||
- **OUT_OF_BRANCH_SCOPE (이관 권고)**: `sub` 기반 충돌감지 전용 authenticator + `email_verified` hard-reject SPI 는 본 branch(flow 구성) 범위 밖 → hub §5 Deferred SPI 트랙 또는 별도 branch(예: `feature-keycloak-firstlogin-emailverified-authenticator`). 형제 sub-vs-email 도 동일 gap 을 §Audit `OUT_OF_BRANCH_SCOPE` 로 이관 권고 중 — 중복 신설 금지, 단일 SPI branch 로 수렴 권고.
|
||||
- **NO_GROUND_TRUTH (한계 명시)**: 코드 repo `/home/donghyeon/workspace/keycloak-patterns/` 미생성(hub §9). `actually-implemented` 주장 불가 — 모든 구현 detail 은 `planned`/`needs-confirmation`. §참조의 ca-tmpl ground truth(registries/error-codes 등)는 **본 프로젝트와 무관**(keycloak-patterns 는 별도 repo) — 계약값 검증 대상 아님.
|
||||
- **BIDIR_LINK (fix 적용)**: [[raw/official-docs/keycloak-first-broker-login-verify-authenticators-official]] 의 Parent 표·`related_branches` 에 본 branch 가 누락돼 있었음(sub-vs-email 만). 본 `/branch-spec` 에서 backlink 추가(양방향 링크 정합, `rules/linking-rules`).
|
||||
- **STALE_SUMMARY 전파 (fix 적용)**: [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]] 가 본 branch D2 를 "Confirm Link + Re-auth `REQUIRED`" 로 요약(L116·L181)했으나 KC-FBLVERIFY 정정(Email 기본 / Re-auth 폴백)과 어긋남 → 같은 세션에서 두 참조를 corrected pointer 로 갱신(consistency-contract §전파).
|
||||
- **DEPTH_LOOP_1 (2026-07-16 depth-audit 반영, §8c 루프 1회)**: `branch-depth-auditor` 가 Blocking 1 + Should-fix 2 를 반환 → 다음 정정: (1) **REPRODUCE_HARNESS (Blocking F1)** — real Google 로는 미소유 email 토큰 발급 불가로 `before(탈취)` 관측 불가 → §구현 가이드 §1 에 **제어 가능 OIDC OP(2nd Keycloak realm / mock OP)** harness 를 `UNSUPPORTED_IMPL_DECISION` + trade-off 로 명세. (2) **VECTOR_SEPARATION (F2)** — AutoLink 는 `email` *값* 매칭이지 `email_verified` 트리거 아님(`KC-FBLVERIFY-C5`) → §1 에 두 벡터 분리 명시, 재현은 Claims To Verify #4(email_verified 발급)에 비의존. (3) **TRUSTEMAIL_DECOUPLE (F3)** — 위임 owner [[raw/branch-notes/feature-keycloak-idp-brokering-google-client]] D6 의 값 선택 근거가 이후 공식 문서로 보강됐지만, core 차단(D1+D2 Confirm Link)은 여전히 `trustEmail` 과 무관하게 성립하고 `trustEmail=false` 는 defense-in-depth 다(fix E2E 가 D6 에 blocking 아님). Advisory F4(Review Profile/version hedge)는 이미 정직 헷지 — 조치 불요.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 아직 없음(문서 단계 — 코드 repo 생성 시 재현/차단 시연에서 발생 예상).
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/google-oidc-discovery-spec]]
|
||||
- [[raw/official-docs/keycloak-first-broker-login-flow]]
|
||||
- [[raw/official-docs/keycloak-first-broker-login-verify-authenticators-official]]
|
||||
- [[raw/official-docs/keycloak-first-login-flow]]
|
||||
- [[raw/official-docs/keycloak-identity-brokering-overview-official]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
> 본 sub-sub-branch 는 leaf — 자식 자료 없음. Phase 3 실 구현 또는 외부 산출물 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 근거 자료
|
||||
|
||||
- [[raw/official-docs/keycloak-first-login-flow]] — D1/D2/D3 (auto-link 경고 + Confirm Link info page + Review Profile 모드)
|
||||
- [[raw/official-docs/keycloak-first-broker-login-verify-authenticators-official]] — D1/D2 (AutoLink WARNING + Verify authenticators 트리거 조건)
|
||||
- [[raw/official-docs/google-openid-connect-oidc]] — D3/D4 전제 (email scope / email_verified 범위)
|
||||
- [[raw/company-tech-blogs/keycloak-google-login-codemancers]] — D2 corroboration (실무 사례)
|
||||
- [[raw/official-docs/keycloak-identity-brokering-overview-official]] — First Login Flow Override 배경
|
||||
|
||||
### 오류 기록 (이 sub-sub-branch 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 문서 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — 실 구현 단계에 누적) "왜 email auto-linking 이 계정탈취인가 / Confirm Link 가 어떻게 막나 / OOTB 기본 flow 는 이미 안전한가?" 후보.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크: (없음 — 코드 repo 미생성)
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: 없음 (현재 문서 단계; done-bar 달성 시 `locally-verified` = `docker compose up` 로컬 재현→차단)
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출): done-bar(재현→차단) `locally-verified` 달성 후 hub §11 정책에 따라 Phase 4 시점 검토. 현재 없음.
|
||||
- **추출하지 않을 항목** (planned / documented-only): 현 시점 전체 (`planned`/`documented-only`).
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
---
|
||||
title: branch / feature-keycloak-four-pattern-tradeoff-matrix
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-PATTERNS-OVERVIEW-019
|
||||
kind: project-work-item
|
||||
project: keycloak-patterns-overview
|
||||
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-019
|
||||
inherits: [DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1, DEC-KEYCLOAK-PATTERNS-OVERVIEW-AUTH-TAXONOMY-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-KEYCLOAK-PATTERNS-OVERVIEW-003, WI-KEYCLOAK-PATTERNS-OVERVIEW-008, WI-KEYCLOAK-PATTERNS-OVERVIEW-010, WI-KEYCLOAK-PATTERNS-OVERVIEW-012]
|
||||
imports: []
|
||||
delegates: []
|
||||
accepts_delegations: []
|
||||
contract_packet: 1
|
||||
contract_packet_sha256: d06cbb2df49a1cb669394e4f97e96c796f4c2fdfed0d7fd63021b63af73e8234
|
||||
branch: feature-keycloak-four-pattern-tradeoff-matrix
|
||||
parent_branch:
|
||||
related_projects: [keycloak-patterns-overview]
|
||||
tags: [branch]
|
||||
created: 2026-07-23
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-four-pattern-tradeoff-matrix
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
- [[raw/project-notes/keycloak-patterns-overview]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: 4패턴 비교표의 모든 cell이 구현 WI evidence를 가리킨다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1` | done-bar는 E2E success와 signature security failure 재현·해결 evidence다 | `WI-KEYCLOAK-PATTERNS-OVERVIEW-019` 완료 조건에 적용 | `[[raw/project-notes/keycloak-patterns-overview]]` |
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-AUTH-TAXONOMY-001@1` | canonical 분류축은 AP1~AP4 인증 통합 아키텍처와 cross-cutting 변형이다 | `WI-KEYCLOAK-PATTERNS-OVERVIEW-019` 완료 조건에 적용 | `[[raw/project-notes/keycloak-patterns-overview]]` |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- GENERATED: artifact-imports:start -->
|
||||
### 가져온 artifact 계약
|
||||
|
||||
| Artifact Ref | Owner | Producer | Schema Ref |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: artifact-imports:end -->
|
||||
|
||||
<!-- GENERATED: project-contract-imports:start -->
|
||||
## 가져온 프로젝트 계약
|
||||
|
||||
| Ref | Owner | 요약 | Branch 적용 |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: project-contract-imports:end -->
|
||||
|
||||
<!-- GENERATED: received-delegations:start -->
|
||||
### 수신한 위임
|
||||
|
||||
| Delegation Ref | From | Concern | Status |
|
||||
|---|---|---|---|
|
||||
<!-- GENERATED: received-delegations:end -->
|
||||
|
||||
<!-- GENERATED: flow:start -->
|
||||
### 가져온 흐름 단계
|
||||
|
||||
| Stage Ref | Order | Owner | Input | Action | Output |
|
||||
|---|---:|---|---|---|---|
|
||||
<!-- GENERATED: flow:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
- `WI-KEYCLOAK-PATTERNS-OVERVIEW-019`의 완료 조건을 구현한다: 4패턴 비교표의 모든 cell이 구현 WI evidence를 가리킨다
|
||||
- 산출물: AP1~AP4 인증 통합 아키텍처의 **트레이드오프 매트릭스** — {토큰 위치 · 인증 강제/검증 주체 · XSS/CSRF surface · 선택 기준 · keycloak 설정} 을 한 표로, 각 cell 은 source claim 또는 구현 WI evidence 를 가리킨다 (§구현 가이드 1 이 표의 owner).
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- Work Item 완료 조건
|
||||
- 매트릭스 스켈레톤(행·열·이론 근거 cell) 작성 — 행 정의는 project `AUTH-TAXONOMY` 결정 소비, cell 근거는 raw source claim 직접 인용 (D1·D2·D4)
|
||||
- Evidence cell 채움 규칙 정의 — 의존 WI 완료 시 어떤 증거를 어떤 등급으로 링크하는지 (D3)
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- project decision registry 변경
|
||||
- 각 패턴의 실 구현·함정 재현 — 의존 WI(003·008·010·012 및 그 자식들) 소유 (`OUT_OF_BRANCH_SCOPE`)
|
||||
- Google IdP brokering 을 별도 행으로 다루는 것 — cross-cutting 변형은 project §2.2 소유, 본 표에는 비고 1줄만 (D1)
|
||||
- 패턴별 세부 설정값(SameSite 값, NetworkPolicy 명세 등) — 해당 구현 WI branch 소유 (`OUT_OF_BRANCH_SCOPE`)
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 이 branch의 구현·설계 결정의 **근거가 되는 외부 자료**. 공식 문서·대기업 기술 블로그·강의 등 raw 자료를 인용.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| `[[raw/official-docs/oauth2-browser-based-apps-ietf-draft]]` | D1 행 구성(BFF/TMB/Browser-client 3패턴 정의 C1~C3) · D4 선택 기준 서열(C4 보안 내림차순, C5 TMB 경량 절충) |
|
||||
| `[[raw/official-docs/oauth2-proxy-overview-config-official]]` | D1 의 AP4 행(IETF 3종 밖 별도 지지 C1 reverse-proxy 인증) · 매트릭스 AP4 행 cell(C4 헤더 전달, C5 OIDC issuer 설정) |
|
||||
| `[[raw/official-docs/owasp-html5-storage-xss-spa]]` | D4 · 매트릭스 XSS surface 열의 AP1 행(C1 localStorage 세션 금지, C2 XSS 1건 전체 탈취) |
|
||||
| `[[raw/official-docs/spring-security-resource-server-jwt]]` | 매트릭스 AP1 행의 검증 주체·keycloak 설정 cell(C1 issuer-uri 검증, C6 audiences 검증) |
|
||||
| `[[raw/company-tech-blogs/curity-bff-pattern-spa]]` | D4 의 AP3 선택 조건(C1 토큰을 브라우저 밖에, C6 refresh 탈취 위험) — **vendor 사례, 공식 기준 승격 금지**(IETF C4 와 결합해서만 사용) |
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [x] 매트릭스 스켈레톤 + source claim 근거 연결 (§구현 가이드 1) — 등급: `documented-only`
|
||||
- [ ] 의존 WI 4개(003·008·010·012) 완료 시 Evidence cell 을 구현 증거 링크·등급으로 교체 (§구현 가이드 2 절차) — 등급: `planned`
|
||||
- [ ] 4패턴 비교표의 모든 cell이 구현 WI evidence를 가리킨다 (완료 조건) — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- 2026-07-23 `NO_GROUND_TRUTH`: 구현 repo `/home/donghyeon/workspace/keycloak-patterns/` 미존재, 의존 anchor WI 4개(003·008·010·012) 전부 `planned`. 따라서 본 세션 산출은 **이론 골격 + 근거 연결까지** — Evidence cell 은 전부 `planned(WI-NNN)` placeholder.
|
||||
|
||||
## 결정 사항
|
||||
|
||||
> 추후 면접/회고에서 "왜 이렇게 했나" 답할 근거. 상세는 아래 `결정-근거 매핑` D-row 가 소유.
|
||||
|
||||
- 2026-07-23: D1 행 = AP1~AP4 4행(Google 은 비고) / 이유: 인증 아키텍처 축이 canonical / 대안: 구 6패턴(배포×federation) 축 / 근거: `[[raw/official-docs/oauth2-browser-based-apps-ietf-draft]]`
|
||||
- 2026-07-23: D2 열 = project §5 의 5열 + Evidence 열 / 이유: 완료 조건이 cell→WI evidence 연결 요구 / 근거: 상속 `ACCEPTANCE-001@1`
|
||||
- 2026-07-23: D3 Evidence cell 규칙 = `planned(WI-NNN)` → 구현 후 `[[wi-slug]] · 등급` / 이유: 미검증 셀의 등급 과장 차단 / 근거: 상속 `ACCEPTANCE-001@1` + CLAUDE.md §6
|
||||
- 2026-07-23: D4 선택 기준 열 = IETF 보안 내림차순 + 조건 분기 / 이유: 벤더 중립 서열 존재 / 대안: 벤더 블로그 권고 서열 / 근거: `[[raw/official-docs/oauth2-browser-based-apps-ietf-draft]]`
|
||||
|
||||
<!-- section-id: decision-evidence -->
|
||||
## 결정-근거 매핑
|
||||
|
||||
> `선택 조건` 열(R2): "이 조건일 때 이 결정, 다른 조건이면 어떤 대안". 분기 없으면 `N/A`.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | 매트릭스 행은 AP1~AP4 4행으로 구성하고, Google IdP brokering 은 행이 아니라 비고 1줄로 둔다. 행 정의 자체는 project `DEC-KEYCLOAK-PATTERNS-OVERVIEW-AUTH-TAXONOMY-001@1` 소비(재정의 아님). | 분류 축이 "누가 토큰을 쥐고 누가 인증을 강제하나"인 동안 이 결정 / 배포 토폴로지·federation 축으로 비교하려면 구 6패턴 표(project 가 2026-07-14 §2.3 으로 강등)로 회귀 | `raw/official-docs/oauth2-browser-based-apps-ietf-draft.md#OAUTH-BBA-C1`, `#OAUTH-BBA-C2`, `#OAUTH-BBA-C3`, `raw/official-docs/oauth2-proxy-overview-config-official.md#OAUTH2PROXY-C4`, `#OAUTH2PROXY-C5` | `official-standard` (IETF 3패턴) + `official-vendor-doc` (AP4 — 검증된 C4·C5; C1 은 소스 재확인 실패로 `needs-confirmation`, 앵커 제외) | AP4 는 IETF 3종 밖 — oauth2-proxy 가 draft 의 BFF 정의와 1:1 이라는 증거 없음(source 의 "Does not prove" 명시). AP4 행 각주로 경계 유지, 검증은 `검증해야 할 주장` #1 |
|
||||
| D2 | 매트릭스 열은 project §5 가 정한 {토큰 위치 · 검증 주체 · XSS/CSRF surface · 선택 기준 · keycloak 설정}([[raw/project-notes/keycloak-patterns-overview]] 소유)에 **Evidence 열 1개를 추가**한 6열로 한다. | 완료 조건이 "모든 cell 이 구현 WI evidence 를 가리킨다"인 동안 Evidence 열 필수 / 이론 비교표만 필요하면 5열로 충분하나 그 경우 본 WI 완료 조건 미충족 | 상속 `DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1`, `raw/official-docs/oauth2-browser-based-apps-ietf-draft.md#OAUTH-BBA-C4` (선택 기준 열의 서열 축) | `project-decision` + `official-standard` | 열 이름·순서는 project §5 문구 개정 시 함께 갱신 필요(pointer 이므로 비차단 전파 대상) |
|
||||
| D3 | Evidence cell 은 `planned(WI-NNN)` placeholder 로 시작하고, 해당 WI 의 E2E + signature 함정 재현·해결 증거 확보 후에만 `[[raw/branch-notes/<wi-slug>]] · locally-verified` 형태로 교체한다. `documented-only` 링크로는 완료 조건 미충족. | Evidence cell 에 적힌 WI 가 하나라도 `planned`/미검증인 동안 이 규칙 / **Evidence cell 에 적힌 모든 WI**(anchor 4개 + 각 행의 자식 WI: 004·005·006·009·011·013·014)가 `locally-verified` 도달 시 전 cell 교체 후 본 branch 완료 선언 | 상속 `DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1` (done-bar = E2E + 함정 재현·해결 evidence) | `project-decision` | `NO_GROUND_TRUTH`(2026-07-23): 구현 repo 자체가 미존재 — 등급 판정 기준이 실측 대상 없이 문서 규칙으로만 존재 |
|
||||
| D4 | '선택 기준' 열은 IETF 보안 내림차순(AP3 BFF > AP2 TMB > AP1 Browser-client; AP4 는 서열 밖 — 백엔드 인증코드 0줄 + 네트워크 격리 의존)을 기본 서열로 하고, 상황 분기(토큰 브라우저 노출 금지→AP3 / 프록시 전량 경유 부담→AP2 / polyglot 다수 백엔드 균일 인증→AP4[heuristic] / 학습·최단 셋업→AP1+PKCE)를 병기한다. | 벤더 중립 서열이 필요한 동안 IETF 서열 채택 / 특정 벤더 스택 고정 상황이면 해당 벤더 권고(예: curity)를 1차 근거로 쓸 수 있으나 본 프로젝트는 표준 우선 | `raw/official-docs/oauth2-browser-based-apps-ietf-draft.md#OAUTH-BBA-C4`, `#OAUTH-BBA-C5`, `raw/company-tech-blogs/curity-bff-pattern-spa.md#CURITY-BFF-C1`, `#CURITY-BFF-C6`, `raw/official-docs/owasp-html5-storage-xss-spa.md#OWASP-HTML5-C1`, `#OWASP-HTML5-C2` | `official-standard` 주도 + `company-case-study` 보조 | IETF 서열은 정성적("decreasing order of security") — 정량 근거 아님. curity C1("유일한 방법")은 vendor 과장 가능성 → IETF C4 와 결합해서만 인용 (`검증해야 할 주장` #4). polyglot→AP4 분기는 claim 미보유 heuristic — `UNSUPPORTED_IMPL_DECISION`(§구현 가이드 1 라벨) |
|
||||
|
||||
<!-- section-id: implementation -->
|
||||
## 구현 가이드
|
||||
|
||||
> **3-rule**: R1 Reference 필수 · R2 UNSUPPORTED_IMPL_DECISION 명시 · R3 OUT_OF_BRANCH_SCOPE 정제 (CLAUDE.md §15.5)
|
||||
|
||||
### 1. 4패턴 트레이드오프 매트릭스 (본 branch 의 deliverable 스켈레톤)
|
||||
|
||||
> **Trace**: 행 구성=D1(OAUTH-BBA-C1~C3, OAUTH2PROXY-C1) · 열 구성=D2(ACCEPTANCE-001@1) · '선택 기준' cell 내용=D4(OAUTH-BBA-C4·C5, CURITY-BFF-C1·C6, OWASP-HTML5-C1·C2) · Evidence cell=D3
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: ① Evidence placeholder 표기를 `planned(WI-NNN)` 텍스트로 통일 — 근거 raw 는 표기 형식을 권고하지 않음. trade-off: 후속 스크립트/사람이 `planned(` prefix 로 미완 cell 을 grep 가능. ② AP4 토큰 위치 cell 의 "세션은 프록시, 백엔드·브라우저 토큰 0" — 세션 저장 위치를 진술하는 official claim 미보유, project §2.1 AP4 행(project-decision) 소비 + 기본 구성 가정. ③ D4 의 polyglot 다수 백엔드→AP4 분기 — claim 미보유 heuristic, 운영 상식 도출.
|
||||
|
||||
| AP | 토큰 위치 | 인증 강제·검증 주체 | XSS/CSRF surface | 선택 기준 | keycloak 설정 | Evidence |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **AP1** SPA-direct + RS | 브라우저(JS) — `OAUTH-BBA-C3` | SPA(public client+PKCE)가 토큰 획득, RS 가 JWT `iss`/`aud` 검증 — `SSRS-JWT-C1`·`C6` | XSS 1건 = 저장 토큰 전체 탈취; localStorage 세션 보관 금지 — `OWASP-HTML5-C1`·`C2` | 학습·최단 셋업, 프론트가 OIDC 완전 제어; 보안 서열 최하 — `OAUTH-BBA-C4` | public client + PKCE, RS `issuer-uri`/`audiences` — `SSRS-JWT-C1`·`C6` | `planned(WI-003·004·005·006)` |
|
||||
| **AP2** Token-Mediating Backend | access→브라우저, refresh→백엔드만 — `OAUTH-BBA-C2` (refresh 보관 위치 문언은 draft §6.2 서술, 실측은 검증 #2·WI-009) | 백엔드(confidential client)가 토큰 획득, 브라우저가 RS 직접 호출 — `OAUTH-BBA-C2` | refresh 는 보호되나 access 는 브라우저 노출 — `OAUTH-BBA-C5`, `CURITY-BFF-C6` | BFF 전량 프록시 부담 없는 경량 절충(BFF 보다 덜 안전, browser-client 보다 안전) — `OAUTH-BBA-C5` | confidential client(+secret), 브라우저에 access 만 전달 — `OAUTH-BBA-C2` | `planned(WI-008·009)` |
|
||||
| **AP3** BFF | 백엔드 세션(브라우저 토큰 0개) — `OAUTH-BBA-C1`, `CURITY-BFF-C3` | 백엔드(confidential client)가 인증+전량 프록시 — `OAUTH-BBA-C1` | 토큰 XSS 면역; cookie 자동첨부 → CSRF surface(방어는 WI-011 소유) — `CURITY-BFF-C4` | 토큰 브라우저 노출 금지 요건일 때; 보안 서열 최상 — `OAUTH-BBA-C4`, `CURITY-BFF-C1` | confidential client + httpOnly session cookie — `CURITY-BFF-C4` | `planned(WI-010·011)` |
|
||||
| **AP4** Edge forward-auth | 프록시 세션(백엔드·브라우저 토큰 0 — `--pass-access-token` 미사용 기본 구성 시, `OAUTH2PROXY-C2` 옵션 활성 시 access 가 upstream 헤더로 전달됨) — project §2.1 AP4 행 소비, `UNSUPPORTED_IMPL_DECISION` ② | 별도 reverse proxy 가 OIDC 수행, 백엔드는 전달 헤더 신뢰(인증코드 0줄) — `OAUTH2PROXY-C4`·`C5` | `X-Forwarded-User` 위조 — 헤더 신뢰 모델에서 논리 도출(위협 재현·차단은 WI-014 완료 조건 소유, project §8.0) | polyglot 다수 백엔드에 균일 인증[heuristic — UNSUPPORTED ③]; IETF 3종 밖 실무 패턴 — D1 Open Risk 참조 | proxy `--oidc-issuer-url` (discovery), `--pass-user-headers` — `OAUTH2PROXY-C4`·`C5` | `planned(WI-012·013·014)` |
|
||||
|
||||
- 비고(행 아님): **Google IdP brokering** 은 어느 AP 에도 realm 설정만으로 얹히는 cross-cutting 변형 — 정의·함정은 [[raw/project-notes/keycloak-patterns-overview]] §2.2 소유.
|
||||
- R3 정제: 각 cell 의 세부 방어 구현(SameSite 값, NetworkPolicy 명세, audience validator 코드)은 Evidence 에 적힌 구현 WI branch 소유 — 본 표는 pointer 만 유지.
|
||||
|
||||
### 2. Evidence cell 채움 절차 (구현 WI 완료 시)
|
||||
|
||||
> **Trace**: D3 (ACCEPTANCE-001@1 도출). 절차만 정의 — 실행은 각 WI 완료 시점.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: cell 교체 단위를 "anchor WI 묶음"이 아니라 **개별 WI**로 함 — 근거 raw 없음. trade-off: 부분 진행을 표에 즉시 반영(전량 대기 시 표가 오래 stale). 다중-WI cell 의 혼합 상태 표기 예: `[[raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce]] · locally-verified + planned(WI-004·005·006)`.
|
||||
|
||||
1. 해당 WI branch 의 `## 완료 후 정리` 에서 E2E 증거(200 OK 로그/스크린샷) + signature 함정 before/after 확인.
|
||||
2. 증거 등급 판정 — `locally-verified` 이상만 인정 (`documented-only` 는 완료 조건 미충족, D3).
|
||||
3. cell 의 `planned(WI-NNN)` → `[[raw/branch-notes/<wi-slug>]] · locally-verified` 로 교체.
|
||||
4. 전 cell 교체 완료 시 본 branch TODO 최종 항목 체크 → `/ingest` 로 `wiki/projects/` 추출 후보.
|
||||
|
||||
<!-- section-id: edge-failure-dependency -->
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- 의존 WI 부분 완료 — cell 단위 독립 교체(§구현 가이드 2), 미완 cell 은 `planned(...)` 유지. 표 전체를 블록하지 않음.
|
||||
- project taxonomy 개정(`AUTH-TAXONOMY-001` @2 발행) — packet 이 @1 고정이므로 preflight 가 `STALE_INHERITANCE_REVISION` 으로 차단 → 행 구성 재검토 후 packet 재생성.
|
||||
- AP4↔IETF-BFF 매핑 드리프트 발견(검증 #1 실패) — 매트릭스 AP4 행 각주 갱신 + D1 Open Risk 재판정. 표 삭제 아님.
|
||||
- **다른 계약 의존**: `WI-KEYCLOAK-PATTERNS-OVERVIEW-003`(AP1 anchor), `WI-KEYCLOAK-PATTERNS-OVERVIEW-008`(AP2), `WI-KEYCLOAK-PATTERNS-OVERVIEW-010`(AP3), `WI-KEYCLOAK-PATTERNS-OVERVIEW-012`(AP4) — frontmatter `depends_on` 은 이 anchor 4개(project registry row 소유). Evidence cell 은 anchor 의 자식 WI(004·005·006·009·011·013·014)도 인용하므로 **완료 선언은 cell 에 적힌 모든 WI 기준**(D3 선택 조건). anchor 완료 조건이 바뀌면 §구현 가이드 2 의 판정 기준도 재검토.
|
||||
|
||||
<!-- section-id: claims-to-verify -->
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| AP4(oauth2-proxy)가 IETF BFF 요건(draft §6.1.3)과 어디서 갈라지는지 | source 가 "1:1 매핑 미증명"을 명시(OAUTH-BBA usage boundary) | WI-012 구현 후 draft §6.1.3 MUST 항목 체크리스트 대조 | `needs-confirmation` |
|
||||
| AP2 에서 refresh token 이 브라우저 network 응답에 나타나지 않는다 | IETF 정의일 뿐 우리 구현의 실측 아님 | WI-009 완료 조건(network 탭/response body 검사)으로 검증 | `planned` |
|
||||
| 매트릭스 각 행의 이론 서술이 single-EC2 실구현에서 재현된다 | `NO_GROUND_TRUTH` — 구현 repo 미존재, anchor WI 전부 planned | 의존 WI 4개의 E2E + 함정 재현 evidence 로 cell 단위 검증 | `planned` |
|
||||
| "토큰을 브라우저 밖에 두는 것이 유일한 보호 방법"(curity C1)의 일반화 | vendor 블로그 표현 — IETF 는 3패턴 모두 trade-off 로 허용(C4) | IETF draft §6.3.2 방어 요건과 대조해 한정 서술(AP3 선택 조건)로만 유지 | `needs-confirmation` |
|
||||
|
||||
## 관심사 커버리지 (coverage-auditor 자동 생성 — 있을 때)
|
||||
|
||||
`/coverage` 실행 전.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
아직 없음.
|
||||
|
||||
## 묶음 (이 branch에서 파생된 자료)
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
해당 없음.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
---
|
||||
title: branch / feature-keycloak-google-claim-attribute-mapping (Google claim → Keycloak attribute mapping)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-PATTERNS-OVERVIEW-017
|
||||
kind: project-work-item
|
||||
project: keycloak-patterns-overview
|
||||
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-017
|
||||
inherits: [DEC-KEYCLOAK-PATTERNS-OVERVIEW-IDP-BROKERING-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-KEYCLOAK-PATTERNS-OVERVIEW-015]
|
||||
contract_packet: 1
|
||||
branch: feature-keycloak-google-claim-attribute-mapping
|
||||
parent_branch:
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, p1b, identity-provider-mappers, claim-mapping]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: f1610a6ccdece483c1cdbf85293c304f640dede279cd7bfe7bf4cdb64e1f6e52
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-google-claim-attribute-mapping (Google claim → Keycloak attribute mapping)
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/project-notes/keycloak-patterns-overview]]의 `WI-KEYCLOAK-PATTERNS-OVERVIEW-017` 직접 branch.
|
||||
> 학습 노트: P1B는 `documented-only` (실 구현 안 함).
|
||||
> `status_label`: `in-progress`
|
||||
|
||||
> **정합 노트 (2026-07-14 감사)**: 본 노트 = Google claim → Keycloak **attribute-mapping owner** (hub Branch 분해 Tier-2 `feature-keycloak-google-claim-attribute-mapping`). 형제 [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]] 와 매핑 내용이 겹치는데, 그쪽은 **role → 권한(RBAC)** 부분만 남기고 §5 deferred 로 분리됨.
|
||||
>
|
||||
> **정합 노트 (2026-07-16 /branch-spec)**: 근거 승격·delegate·구현 명세 채움 회차. §Decision Evidence Map 에 `선택 조건`(R2) 열 추가, D3 근거 official 승격, D5(`hd`→role) 형제로 delegate, `## 구현 가이드`·`## 엣지·실패·의존` 신설. 자동조사 dispatch 0회(근거 이미 아카이브됨). 상세는 §Audit & Findings. coverage: `governing_docs` 미지정 + `related_projects: [keycloak-patterns]` → **면제**(coverage-gate §7).
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/project-notes/keycloak-patterns-overview]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: Google email·name claim이 Keycloak attribute로 매핑된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-IDP-BROKERING-001@1` | Google federation은 Keycloak IdP brokering과 hardened First Broker Login을 사용한다 | Google claim을 Keycloak user attribute로 매핑하는 정책에 적용한다 | [[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 -->
|
||||
## 목표
|
||||
|
||||
Google ID token에는 `sub`, `email`, `email_verified`, `name`, `given_name`, `family_name`, `picture`, `locale` 등 표준 claim이 포함된다. 이를 Keycloak user의 속성(`firstName`, `lastName`, `email`, custom attribute) 또는 role로 매핑하려면 **Identity Provider Mapper**를 설정해야 한다.
|
||||
|
||||
본 노트는 mapper 종류, sync mode, primary key 선택 (sub vs email)의 함의를 정리한다.
|
||||
|
||||
**핵심 통찰:**
|
||||
- **`sub` claim은 영구·불변** (Google이 사용자별로 발급한 고유 ID). **email은 변경 가능 / 재사용 가능** (자세한 분석은 [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]]).
|
||||
- Keycloak의 federated identity 테이블은 **`identity_provider` + `provider_user_id` (= Google `sub`)**를 primary key로 사용 → email이 바뀌어도 link 유지.
|
||||
- Mapper 모드 (`IMPORT`, `LEGACY`, `FORCE`, `INHERIT`)에 따라 first login 시점에만 매핑 / 매 로그인마다 갱신 / IdP 설정 상속 등으로 동작이 달라짐.
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
> 본 노트 = **attribute-mapping owner** (2026-07-14 감사). claim → *role(RBAC)* 은 형제 소유(아래 delegate).
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- Google ID token 표준 claim → Keycloak **user attribute** 매핑 (Attribute Importer): `email` / `given_name` / `family_name` / `picture` (D4)
|
||||
- federated identity primary key = Google `sub` (built-in, mapper 불필요) (D1)
|
||||
- Username 생성 정책 (Username Template Importer): `${ALIAS}.${CLAIM.sub}` (D2)
|
||||
- **IdP-level** Sync Mode 선택 (IMPORT vs FORCE) — attribute 최신성 정책 (D3)
|
||||
- mapper 종류 카탈로그 정리 (D6, `needs-confirmation`)
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 대부분 **다른 owner 브랜치로 delegate** — §엣지·실패·의존 의 "다른 계약 의존" 참조.
|
||||
|
||||
- **claim → role (RBAC 인가) 매핑** (`hd`→role 포함) — owner [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]] (D5 delegate)
|
||||
- **account linking key 안전성** (sub vs email 계정탈취) — owner [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]]
|
||||
- **First Broker Login Flow `email_verified` 게이트 / email auto-linking 방어** — owner [[raw/branch-notes/feature-keycloak-first-broker-login-flow]]
|
||||
- **Client Scope Mapper** (Keycloak user attribute → access token claim, 2단계 전파) — client-level (P2A 계열), 본 IdP-level 범위 밖
|
||||
- 실제 코드/배포 — 본 P1B sub-sub-branch 전체 `documented-only` / `planned`
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
- [[raw/official-docs/keycloak-identity-provider-mappers]] — Keycloak IdP Mapper 종류 / sync mode 공식 (mapper 종류 `KC-IDP-MAPPER-C4` 는 verbatim 부재 → `needs-confirmation`)
|
||||
- [[raw/official-docs/google-openid-connect-oidc]] — Google ID token 표준 claim (`GOIDC-C3` `sub` 영구·`email` primary key 금지 / `GOIDC-C4` `email` scope)
|
||||
- [[raw/official-docs/google-oidc-discovery-spec]] — D5 (`hd`) 와 D1 (`sub`) 보강: `GOOGLE-OIDC-C7` (`hd` = Google Workspace/Cloud org domain, verbatim) + `GOOGLE-OIDC-C6` (`sub` unique among all Google Accounts and never reused) + `GOOGLE-OIDC-C4` (scope = `openid` + `profile`/`email`). 기존 `related_branches` 에 본 브랜치가 이미 포함돼 있었으나 Sources 표에 미등록이었음 → 2026-07-16 추가
|
||||
- [[raw/official-docs/keycloak-identity-brokering-overview-official]] — federated identity 모델
|
||||
- [[raw/official-docs/keycloak-identity-provider-sync-mode-official]] — D3의 IdP-level attribute Sync Mode 공식 근거(IMPORT/FORCE/LEGACY/INHERIT verbatim). 이전 `keycloak-identity-provider-mappers.md#KC-IDP-MAPPER-C5` 의 `needs-confirmation` gap 을 메움
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급. 본 P1B sub-sub-branch는 전체 `documented-only` / `planned`.
|
||||
|
||||
- [ ] Identity Provider Mapper 종류 정리 — 등급: `documented-only`
|
||||
- **Attribute Importer**: Google claim → Keycloak user attribute (예: `picture` claim → `picture` user attribute)
|
||||
- **Username Template Importer**: Google claim 조합으로 Keycloak username 생성 (예: `${CLAIM.email}` 또는 `${ALIAS}.${CLAIM.sub}`)
|
||||
- **Hardcoded Role**: 본 IdP로 로그인한 모든 사용자에게 특정 role 부여 (예: `realm:user`)
|
||||
- **Hardcoded Attribute**: 모든 broker 사용자에게 같은 속성값 부여
|
||||
- **Claim to Role**: Google claim 값에 따라 조건부 role 매핑 (예: `hd` claim = `mycompany.com` → `realm:employee`)
|
||||
- **Advanced Attribute to Role / Claim to Group**: 복합 조건 매핑
|
||||
- [ ] 표준 Google claim 매핑 계획 — 등급: `planned`
|
||||
- `email` → Keycloak `email` (자동 매핑 가능)
|
||||
- `given_name` → Keycloak `firstName`
|
||||
- `family_name` → Keycloak `lastName`
|
||||
- `picture` → Keycloak custom attribute `picture`
|
||||
- `sub` → Keycloak federated identity `provider_user_id` (자동, mapper 불필요)
|
||||
- [ ] Sync Mode 비교 정리 — 등급: `documented-only`
|
||||
- **IMPORT**: first login 시점에만 attribute 복사. 이후 Google 측 변경 무시.
|
||||
- **LEGACY**: deprecated.
|
||||
- **FORCE**: 매 로그인마다 Google claim으로 Keycloak attribute 덮어쓰기. Google 측 변경 자동 반영.
|
||||
- **INHERIT**: IdP 설정의 default sync mode 상속.
|
||||
- [ ] Username 생성 정책 결정 — 등급: `documented-only`
|
||||
- 옵션 A: `email`을 username으로 (가독성 ↑, email 변경 시 username 변경 문제)
|
||||
- 옵션 B: `${ALIAS}.${CLAIM.sub}` (예: `google.1234567890`, 영구 안정 / 가독성 ↓)
|
||||
- 권장: B (불변성 우선)
|
||||
- [ ] `hd` (hosted domain) claim 활용 검토 — 등급: `documented-only` — **DELEGATED → 형제 [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]]** (role/RBAC owner, D5 참조). 본 노트는 `hd` 를 *attribute* 로 import 하는 경우만 §구현 가이드 §1 방식 재사용, *role* 매핑은 형제 소유.
|
||||
- Google Workspace 사용자의 경우 `hd=mycompany.com` claim 제공 (사실 근거: `GOOGLE-OIDC-C7`)
|
||||
- "Claim to Role" mapper로 사내 도메인 사용자에게 자동 role 부여 가능 (→ 형제 D3)
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- Keycloak 25.x 기준 Mapper 설정 UI는 IdP 상세 페이지의 `Mappers` 탭. 새 mapper 추가는 `Add mapper` 버튼.
|
||||
- `Attribute Importer`의 `Claim` 필드는 Google claim 이름 그대로 (예: `email`, `given_name`). 중첩 claim은 dot notation (예: `address.locality`).
|
||||
- `Username Template Importer`는 first login 시점에만 실행 (이후 username 변경 없음) — 따라서 Sync Mode와 무관하게 IMPORT 동작.
|
||||
- email을 username으로 쓰면 사용자 friendly하지만, Google에서 email alias 변경 / 회사 이메일 재배정 시 충돌 발생 가능 → 본 프로젝트는 sub 기반 username 권장.
|
||||
- federated identity 자체는 `sub` 기반 — mapper와 별개로 Keycloak이 internal하게 관리.
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
> 본 sub-sub-branch는 `documented-only` — 실 환경 결정 없음. **만약 구현한다면** 권장 기본값.
|
||||
|
||||
- 2026-05-25: federated identity primary key = Google `sub` claim. email 변경에 robust.
|
||||
- 2026-05-25: Username 생성 = `${ALIAS}.${CLAIM.sub}` (불변성 우선). 사용자에게 보이는 display name은 별도 `name` attribute로 분리.
|
||||
- 2026-05-25: Sync Mode = **IMPORT** (first login 시점 매핑만). 매 로그인마다 덮어쓰기는 사용자 직접 변경한 Keycloak attribute를 매번 되돌려 UX 저하.
|
||||
- 2026-05-25: `email` / `given_name` / `family_name` / `picture` 4개 Attribute Importer 매핑 기본.
|
||||
- 2026-05-25: `hd` claim 기반 role 매핑은 Google Workspace 도입 시점에 추가 (현재는 보류). → **2026-07-16 정합**: 이 `hd`→role 결정은 role/RBAC owner 인 형제 [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]] D3 소유로 **delegate**(본 노트는 attribute-mapping owner). Decision Evidence Map D5 참조.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 본 sub-sub-branch 는 `documented-only`. cited raw: `keycloak-identity-provider-mappers`, `google-openid-connect-oidc`, `google-oidc-discovery-spec`, `keycloak-identity-provider-sync-mode-official`, `keycloak-identity-brokering-overview-official`.
|
||||
> **2026-07-16 정합 (/branch-spec)** — 상세는 §Audit & Findings: (1) **D3** 근거를 `KC-IDP-MAPPER-C5`(needs-confirmation) → `KC-SYNCMODE-C3/C4/C1`(official-vendor-doc)로 승격 (Sync Mode verbatim 회수됨). (2) **D5**(`hd`→role)를 형제 [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]] D3 으로 **delegate** — 본 노트는 attribute-mapping owner, role/RBAC 는 형제 소유. `hd` 사실 근거는 `GOOGLE-OIDC-C7`(verbatim)로 확보. (3) mapper 종류(`KC-IDP-MAPPER-C4`)는 여전히 `needs-confirmation` — Admin UI 캡처 대상.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | federated identity primary key = Google `sub` claim — email 변경에 robust | N/A — `sub` 는 불변·재사용 없음이라 항상 primary key. `email` 은 어떤 조건에서도 primary key 부적합("shouldn't use email … Always use the sub") | `raw/official-docs/google-openid-connect-oidc.md#GOIDC-C3` ("Always use the `sub` field as it is unique to a Google Account even if the user changes their email address") + `raw/official-docs/google-oidc-discovery-spec.md#GOOGLE-OIDC-C6` (`sub` unique among all Google Accounts and never reused) | `official-vendor-doc` | Keycloak 의 `provider_user_id` 가 정확히 `sub` 로 매핑된다는 verbatim 은 cited raw 에 부재 — Server Admin Guide "Federated Identity" 섹션 raw 추가 필요 (→ Claims To Verify) |
|
||||
| D2 | Username 생성 = `${ALIAS}.${CLAIM.sub}` (불변성 우선); display name 은 별도 `name` attribute 로 분리 | username **안정성(불변)** 우선 시 → sub 기반. **가독성** 우선 + email 재배정/충돌 없음 보장 시 → `${CLAIM.email}` (대안). 학습 노트는 불변성 우선 | `raw/official-docs/google-openid-connect-oidc.md#GOIDC-C3` (email 변경 가능, `sub` 불변) — 배경 정당화. **Username Template Importer 의 `${ALIAS}.${CLAIM.sub}` syntax 자체는** `raw/official-docs/keycloak-identity-provider-mappers.md#KC-IDP-MAPPER-C4` 가 `needs-confirmation` (verbatim 부재) | `official-vendor-doc (sub 불변) + needs-confirmation (mapper syntax)` | Username Template Importer syntax verbatim 확보 필요 (Keycloak 소스 또는 admin UI 캡처) |
|
||||
| D3 | **IdP-level default Sync Mode = `IMPORT`** (profile attribute는 first login 시점 매핑) | 사용자가 Keycloak 에서 **직접 편집한 attribute 를 보존**해야 하면 → IMPORT. Google 측 name/picture 변경을 **매 로그인 자동 반영**해야 하면 → IdP-level FORCE (대안) | `raw/official-docs/keycloak-identity-provider-sync-mode-official.md#KC-SYNCMODE-C3` (`import` = first login 시점 데이터 import, verbatim) + `#KC-SYNCMODE-C4` (`force` = update user data at each user login) + `#KC-SYNCMODE-C1` (IdP-level `Sync Mode` = 모든 mapper default) | `official-vendor-doc` | Keycloak 25.x Admin UI 드롭다운 라벨과 실제 attribute 반영 시점은 realm export/UI 실측 전까지 `needs-confirmation` |
|
||||
| D4 | `email` / `given_name` / `family_name` / `picture` 4개 Attribute Importer 매핑 | N/A — 표준 프로필 claim 을 Keycloak user model 로 옮기는 기본 매핑(선택 분기 없음). scope 는 `openid profile email` 필요 | `raw/official-docs/keycloak-identity-provider-mappers.md#KC-IDP-MAPPER-C1` (incoming token → user/session attribute) + `#KC-IDP-MAPPER-C3` (external credential → user model) + `raw/official-docs/google-oidc-discovery-spec.md#GOOGLE-OIDC-C4` (scope 는 `openid` + `profile`/`email`) | `official-vendor-doc (general mapper + scope) + needs-confirmation (Attribute Importer 화면값)` | Attribute Importer 설정 화면값 verbatim 부재. `given_name`/`family_name`/`picture` 개별 claim 의 Google verbatim 부재 (C4 는 scope 규칙만) |
|
||||
| D5 | `hd` (hosted domain) claim 기반 **role 매핑** — **DELEGATED** (본 노트 결정 범위 밖) | 본 노트(attribute-mapping owner) 범위 밖 — claim → **role(RBAC)** 결정은 형제가 소유. `hd`→role 은 형제 D3 이 결정(Workspace 도입 시 Advanced Claim to Role 추가, 현재 보류) | delegation → [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]] D3. `hd` 사실 근거 = `raw/official-docs/google-oidc-discovery-spec.md#GOOGLE-OIDC-C7` ("The domain associated with the Google Workspace or Cloud organization of the user.") | `delegated (hd 사실근거 official-vendor-doc)` | personal Gmail 의 `hd` 부재 시 mapper 동작(null/skip) — `GOOGLE-OIDC-C7` "does not prove", 형제 노트에서 검증 |
|
||||
| D6 | mapper 종류 5+ 존재 (Attribute Importer, Username Template Importer, Hardcoded Role, Hardcoded Attribute, Claim to Role, Advanced Claim to Role) | N/A — 사실(존재) 진술, 선택 분기 아님 | `raw/official-docs/keycloak-identity-provider-mappers.md#KC-IDP-MAPPER-C4` (구체적 mapper 종류 목록 — strength: `needs-confirmation`, verbatim 부재) | `needs-confirmation` | Keycloak Admin UI 캡처 + Server Admin Guide sub-page 직접 발췌로 mapper 종류 verbatim 확보 |
|
||||
|
||||
- [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]] D1 — role mapper freshness policy. Role freshness는 본 D3의 IdP-level attribute sync policy와 분리된 별도 owner가 결정한다.
|
||||
|
||||
## Audit & Findings (2026-07-16 /branch-spec)
|
||||
|
||||
> `/branch-spec` 채움 회차의 정합·이관 기록. 결정 본문 아님(추적용). 자동조사 dispatch 0회 — 필요한 근거가 이미 repo 에 아카이브돼 있었음.
|
||||
|
||||
- **SYNC_MODE_VERBATIM_RECOVERED (D3)**: 근거를 `KC-IDP-MAPPER-C5`(needs-confirmation) → `KC-SYNCMODE-C3/C4/C1`(official-vendor-doc)로 승격. Sync Mode verbatim 을 [[raw/official-docs/keycloak-identity-provider-sync-mode-official]] 이 회수(2026-07-15). 원 소스 `keycloak-identity-provider-mappers.md#KC-IDP-MAPPER-C5` 파일 자체의 strength 승격은 별도 migrate 대상(본 task 밖).
|
||||
- **HD_CLAIM_EVIDENCE_FOUND (D5)**: `hd` 사실 근거가 cited `google-openid-connect-oidc` 엔 없었으나, 이미 repo 에 있던 [[raw/official-docs/google-oidc-discovery-spec]] `#GOOGLE-OIDC-C7`(Workspace domain, verbatim)이 커버 → Sources 표에 추가(그 raw 의 `related_branches` 엔 이미 본 브랜치 포함돼 있었음). D5 는 `UNSUPPORTED_DECISION` 이 아니라 형제로 **delegate**.
|
||||
- **RESTATED_FOREIGN_DECISION (D5)**: `hd`→role 은 형제 [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]] D3 소유(2026-07-14 감사의 attribute/role owner 분리). 본 노트에서 결정으로 재진술하지 않고 delegate 포인터로 전환.
|
||||
- **SYNC_MODE_OWNERSHIP_OVERLAP (2026-07-18 해소)**: 본 노트 D3는 **IdP-level attribute sync policy**만 소유한다. Role freshness 정책은 Decision Evidence Map 아래의 direct-owner pointer로 위임했고, 본 노트에서는 값·메커니즘을 재서술하지 않는다.
|
||||
- **BACKREF_IMPACT (비차단)**: 본 결정 표 수정으로 [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] · [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]] 의 D1/D3/D4/D5 참조가 영향받을 수 있음. D1/D3/D4 는 의미 불변(evidence 보강만) → 참조 유효. D5 는 의미 변경(deferred → delegated) → 참조처 요약 대조 필요(§Claims To Verify 아래 처리 / `/sync`).
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> **본 branch 는 `documented-only`** — 실 Keycloak 등록 전 사전 명세. **IdP-level Mapper**(Identity Provider → Mappers 탭)만 다룬다. user attribute → access token claim 전파(Client Scope Mapper)는 client-level 이라 본 범위 밖(§범위 Out of scope). Keycloak 25.x 기준.
|
||||
|
||||
### 1. Attribute Importer mapper 카탈로그 (D4)
|
||||
|
||||
> **Trace**: D4 — `KC-IDP-MAPPER-C1`(incoming token → user attribute) + `KC-IDP-MAPPER-C3`(external credential → user model) + `GOOGLE-OIDC-C4`(scope = `openid profile email`). Sync Mode 계층은 §3(D3).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (a) mapper instance 명(`google-*`)은 임의 명명 — 근거 raw 에 명명 규칙 없음. trade-off: `google-<claim>` prefix 로 provider 출처+대상 claim 을 한눈에. (b) Attribute Importer 의 정확한 UI 필드명(`Claim` / `User Attribute Name`)과 built-in vs custom attribute 구분은 `KC-IDP-MAPPER-C4` `needs-confirmation` — Admin UI 캡처로 확정.
|
||||
|
||||
| mapper instance (임의명) | mapper type | Google Claim | Keycloak target attribute | 비고 |
|
||||
|---|---|---|---|---|
|
||||
| `google-email` | Attribute Importer | `email` | `email` (built-in user field) | scope `email` 필요(`GOOGLE-OIDC-C4`). email 은 primary key 아님(D1) |
|
||||
| `google-given-name` | Attribute Importer | `given_name` | `firstName` (built-in) | scope `profile` |
|
||||
| `google-family-name` | Attribute Importer | `family_name` | `lastName` (built-in) | scope `profile` |
|
||||
| `google-picture` | Attribute Importer | `picture` | `picture` (custom attribute) | URL 만료 가능(§엣지) |
|
||||
|
||||
### 2. Username Template Importer (D2)
|
||||
|
||||
> **Trace**: D2 — `GOIDC-C3`(sub 불변)이 sub 기반 username 을 정당화. syntax 자체는 `KC-IDP-MAPPER-C4` `needs-confirmation`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: template 문자열 `${ALIAS}.${CLAIM.sub}` 의 정확한 placeholder 문법·구분자는 verbatim 미확보 — Admin UI/소스 확인 필요. trade-off: `${ALIAS}` prefix 로 다중 IdP username 충돌 방지 + `sub` 로 불변성.
|
||||
|
||||
- Username Template: `${ALIAS}.${CLAIM.sub}` → 예 `google.1234567890`
|
||||
- 실행 시점: **first login 만** (username 은 이후 불변) — Sync Mode 와 무관하게 IMPORT 동작(진행 중 메모).
|
||||
|
||||
### 3. IdP-level attribute Sync Mode (D3)
|
||||
|
||||
> **Trace**: D3 — `KC-SYNCMODE-C1`(IdP-level `Sync Mode` = 모든 mapper default) + `KC-SYNCMODE-C3`(`import` = first login) + `KC-SYNCMODE-C4`(`force` = each login). **official-vendor-doc — UNSUPPORTED 아님.**
|
||||
|
||||
- **IdP-level `Sync Mode` = `IMPORT`** — §1 의 profile Attribute Importer default 로 상속.
|
||||
- profile attribute를 매 로그인 갱신해야 하는 환경에서는 IdP-level `FORCE`를 대안으로 선택한다(D3).
|
||||
|
||||
### 4. federated identity (D1) — built-in, mapper 불필요
|
||||
|
||||
> **Trace**: D1 — `GOIDC-C3` + `GOOGLE-OIDC-C6`. Keycloak 이 `(identity_provider, provider_user_id=sub)` 로 internal 관리 → 별도 mapper 없음.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `provider_user_id == sub` 매핑은 Keycloak 내부 동작으로 추정 — verbatim 미확보(Claims To Verify). trade-off: mapper 로 강제하지 않고 built-in 신뢰(공식 문서가 별도 mapper 를 요구하지 않음).
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4(깊이 게이트) 캡처용. 정상 매핑 경로 외 실패/엣지 + 다른 계약 의존.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **`email_verified=false` Google 계정**: attribute import 자체는 수행되나 계정 신뢰/링크 안전성은 별개 관심사 — First Broker Login Flow 게이트에 의존(아래). 게이트 없으면 email auto-linking 계정탈취 위험.
|
||||
- **personal Gmail (`hd` 부재)**: `hd` claim 이 없어 `hd` 기반 mapper 는 매칭 안 됨(null/skip 추정). `GOOGLE-OIDC-C7` "does not prove" — 실동작 검증은 형제(D5 delegate).
|
||||
- **`picture` URL 만료**: Google profile picture URL 은 OAuth scope 만료/변경 시 깨질 수 있음 → 장기 저장 시 stale. 표시용으로만 쓰고 캐싱/재조회 정책 별도(needs-confirmation).
|
||||
- **Sync Mode IMPORT 부작용(의도됨)**: Google 측 name/picture 변경이 Keycloak 에 반영 안 됨 → 최신성 필요 시 FORCE 로 전환(D3 대안).
|
||||
- **중첩 claim**: dot notation(`address.locality`) — 진행 중 메모 기준, verbatim 부재(needs-confirmation).
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] **D1**(linking key = `sub`) 에 의존 — 본 노트 D1(primary key = sub)이 그 결정을 consume. linking key 가 email 로 바뀌면 본 노트 primary-key 전제 붕괴.
|
||||
- [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]] **D3**(`hd`→role) 에 본 노트 D5 를 delegate — role/RBAC owner.
|
||||
- [[raw/branch-notes/feature-keycloak-idp-brokering-google-client]] **D4**(scope = `openid profile email`) 에 의존 — 본 노트 §구현 가이드 §1 의 Attribute Importer 는 그 브랜치가 IdP client 에 `openid profile email` scope 를 등록해야만 `email`/`given_name`/`family_name`/`picture` claim 이 채워짐(GOOGLE-OIDC-C4). scope 가 축소되면 해당 attribute 가 빈 값.
|
||||
- [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] — `email_verified` 게이트 + email auto-linking 방어 owner (해당 브랜치 결정 번호화 시 그 Decision ID 로 상향 링크).
|
||||
- **Client Scope Mapper**(user attribute → access token claim, 2단계 전파) — client-level(P2A 계열) owner, 본 IdP-level 범위 밖.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| Keycloak 의 `federated_identity` 테이블이 `(identity_provider, provider_user_id)` 를 primary key 로 사용하고 `provider_user_id` 가 Google `sub` 와 동일 | Keycloak 내부 스키마의 verbatim 인용 없음 | Keycloak Server Admin Guide "Federated Identity" 섹션 raw 추가 + docker 컨테이너의 H2/Postgres 스키마 직접 확인 | `needs-confirmation` |
|
||||
| Username Template Importer mapper 의 `${ALIAS}.${CLAIM.sub}` syntax 가 실제 동작 | mapper 의 verbatim 인용이 cited raw 에 부재 | Keycloak 25.x docker 실행 후 mapper 등록 + 실제 Google 로그인 → username 생성 결과 확인 | `planned` |
|
||||
| Sync Mode IMPORT 가 first login 시점에만 attribute 매핑, FORCE 는 매 로그인마다 덮어쓰기 | verbatim 은 회수됨(`KC-SYNCMODE-C3`/`C4`, official) — 남은 불확실성은 (a) Keycloak 25.x Admin UI 드롭다운 라벨이 AsciiDoc 원문과 일치하는지 (b) 실제 런타임 반영 시점(token refresh vs full re-login) | Keycloak 25.x 에서 IMPORT vs FORCE 토글 + 2회 로그인 + Google 측 name 변경 시 Keycloak DB 반영 차이 캡처 | `planned` |
|
||||
| Google `hd` claim 이 Workspace 사용자에게만 제공되며 hosted domain 값을 담음 | `hd` = Workspace/Cloud org domain 의 verbatim 은 회수됨(`GOOGLE-OIDC-C7`, official) — 남은 불확실성은 **personal Gmail 의 `hd` 부재 시** mapper 동작(null/skip/거부). C7 "does not prove" 명시. (본 항목은 D5 delegate 대상 — 형제 [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]] 에서 검증) | Workspace 계정 + 개인 Gmail 각각 로그인하여 ID Token 의 `hd` 유무 + Keycloak mapper 반영 차이 확인 | `needs-confirmation` |
|
||||
| Google `given_name` / `family_name` / `picture` claim 이 `profile` scope 요청 시 제공 | cited GOIDC-C4 는 `email` claim 만 다룸 | Google OIDC claims table 의 `profile` scope 섹션 verbatim 발췌 추가 | `needs-confirmation` |
|
||||
| `picture` URL 의 만료/CDN 캐싱 정책 — 장기 저장 시 깨질 수 있음 | Google 측 정책의 verbatim 인용 없음 | Google People API / OIDC `picture` claim 공식 문서 raw 추가 | `needs-confirmation` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 아직 없음(문서 단계).
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/google-oidc-discovery-spec]]
|
||||
- [[raw/official-docs/google-openid-connect-oidc]]
|
||||
- [[raw/official-docs/keycloak-google-idp-setup]]
|
||||
- [[raw/official-docs/keycloak-identity-provider-mappers]]
|
||||
- [[raw/official-docs/keycloak-identity-provider-sync-mode-official]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
- [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]]
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
> 본 sub-sub-branch 는 leaf — 자식 자료 없음. Phase 3 P3A 실 구현 또는 외부 산출물 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 오류 기록 (이 sub-sub-branch 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 documented-only 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — Phase 3 실 구현 단계에 누적)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크: (없음, 문서까지만)
|
||||
- 머지 결과 / 배포 환경: 없음 (`documented-only`)
|
||||
- **wiki 추출 대상:** 없음. P1B sub-sub-branch는 root 정책상 wiki/projects/ 승급 안 함.
|
||||
- **추출하지 않을 항목:** 본 sub-sub-branch 전체 (`documented-only` / `planned`).
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
---
|
||||
title: branch / feature-keycloak-google-redirect-uri-policy (P3B Google OAuth client 등록 — redirect_uri 정책 + URL 변경 시 갱신)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-CHILD-9019B40A
|
||||
kind: branch-child
|
||||
project: keycloak-patterns-overview
|
||||
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-017
|
||||
inherits: [DEC-KEYCLOAK-PATTERNS-OVERVIEW-IDP-BROKERING-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-KEYCLOAK-PATTERNS-OVERVIEW-015]
|
||||
contract_packet: 1
|
||||
branch: feature-keycloak-google-redirect-uri-policy
|
||||
parent_branch: feature-keycloak-google-claim-attribute-mapping
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, p3b, google-oauth, idp-brokering, redirect-uri, consent-screen]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 2f73e474940f2931d53b3b512bda9bf501dd0801196d19e86bede25c36ba5656
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-google-redirect-uri-policy (P3B Google OAuth client 등록 — redirect_uri 정책 + URL 변경 시 갱신)
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]]의 child branch. **Google Cloud Console에 OAuth 2.0 client 생성하고 redirect_uri 등록하는 절차**, 그리고 **ngrok 무료 plan URL이 변경될 때마다 Console을 갱신해야 하는 운영 burden** 학습.
|
||||
> 본 sub-sub-branch는 **문서까지만** — 실 Google Cloud project 생성 / OAuth client 등록 / Keycloak Admin IdP 등록은 진행하지 않음. 등급 `documented-only`.
|
||||
> `status_label`: `in-progress`
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: Google email·name claim이 Keycloak attribute로 매핑된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-IDP-BROKERING-001@1` | Google federation은 Keycloak IdP brokering과 hardened First Broker Login을 사용한다 | Google OAuth redirect URI가 Keycloak broker endpoint와 일치하도록 하는 정책에 적용한다 | [[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 -->
|
||||
## 목표
|
||||
|
||||
P3B의 brokering 흐름이 동작하려면 다음 3개 좌표가 글자 단위로 일치해야 한다:
|
||||
1. **Google Cloud Console "Authorized redirect URIs"**에 등록된 URL
|
||||
2. **Keycloak Admin → Identity Providers → Google**에서 발급하는 callback URL
|
||||
3. 실제 사용자 브라우저가 Google → Keycloak으로 redirect 받을 때의 URL
|
||||
|
||||
이 3개가 어긋나면 Google이 `redirect_uri_mismatch` 에러로 인증 차단. 그래서 ngrok 무료 plan(URL 매 세션 변경)을 쓰면 매번 Google Console에 들어가 redirect_uri를 새 URL로 갱신해야 한다 — 이 운영 burden이 sub-sub-branch `-6-1`에서 **Cloudflare Tunnel 정적 도메인 선택**의 결정 근거.
|
||||
|
||||
면접에서 답해야 할 질문:
|
||||
1. Keycloak callback URL의 정확한 포맷은? → `https://<keycloak-domain><relative-path>/realms/<realm>/broker/<idp-alias>/endpoint`
|
||||
2. Google OAuth client의 "Authorized JavaScript origins"는 왜 필요한가? → 본 시나리오에서는 불필요(server-to-server brokering). SPA가 Google과 직접 통신하면 필요.
|
||||
3. Verification screen이 무엇이고 언제 필요한가? → basic identity scope(`openid email profile`)만 쓰는 학습 앱은 test-user allowlist·100명 상한·7일 만료 예외다. sensitive/restricted scope를 추가할 때 별도 verification 조건을 검토한다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- Google Cloud Console OAuth 2.0 Client ID 생성 절차 (web application 타입)
|
||||
- Authorized JavaScript origins / Authorized redirect URIs 정책
|
||||
- `client_id` + `client_secret` 발급 후 Keycloak Admin Console 입력 위치
|
||||
- Verification screen (consent screen) 설정 — test users / scopes / app domain
|
||||
- ngrok URL 변경 시 Google Console 갱신 흐름 (수동 작업 순서)
|
||||
- Cloudflare Tunnel 정적 도메인이 운영 burden 감소시키는 결정 근거 정리
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- Google Workspace SAML federation (OIDC만)
|
||||
- Google Sign-In JS SDK 직접 사용 (Keycloak 우회 시나리오)
|
||||
- Google API Scopes 확장 (Gmail / Drive 등) — 본 학습은 `openid email profile`만
|
||||
- 다른 OIDC Provider(GitHub / Auth0) 등록 비교
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
- [[raw/official-docs/google-oauth2-redirect-uri-validation-official]] — Google redirect_uri 검증 규칙 공식 (D1~D4, D8 근거)
|
||||
- [[raw/official-docs/keycloak-google-idp-setup]] — Keycloak Google IdP 설정 가이드 (D1, D5 근거)
|
||||
- [[raw/official-docs/keycloak-first-login-flow]] — First Broker Login "Confirm Link Existing Account" + 자동 link 보안 경고 공식 (D7 근거 — `/branch-spec` 보강, 2026-07-16)
|
||||
- [[raw/official-docs/google-oauth-app-verification-state-overview-official]] — Google App Verification "OAuth app state overview": Testing/External 앱은 basic identity scope(`openid`/`email`/`profile`)만 요청하면 allowlist 없이 임의 사용자 접근 가능, verification(Published-Verified)은 sensitive/restricted scope 요청 앱에 required — D5의 "sensitive scope 회피 → verification 불필요" 부분 developer-doc 측 근거 보강 (2026-07-16)
|
||||
- [[raw/official-docs/google-oauth2-client-application-types-official]] — Google OAuth client Application-type 분류(Web application vs Native[Android/iOS/Desktop/UWP] vs TV & Limited-Input) + Private/Public Client 정의 공식 (D6 근거 — `wiki-source-summarizer` 보강, 2026-07-16)
|
||||
- [[raw/official-docs/google-oauth-manage-app-audience-official]] — Google OAuth publishing status(Testing/In production) + basic identity scope(name/email/profile) 예외 공식 (D5 verification-policy 부분 근거 — `wiki-source-summarizer` 보강, 2026-07-16)
|
||||
- [[raw/official-docs/google-oauth2-web-server-flow-official]] — Google "Using OAuth 2.0 for Web Server Applications" 공식 문서. confidential/server-to-server flow 정의, "Web application" application type 선택 지침, redirect URIs 요구사항 근거 (D6 근거 보강 — `wiki-source-summarizer`, 2026-07-16). 단 JavaScript origins 미언급 — D6 의 "JS origins 비움" 부분은 여전히 `UNSUPPORTED_DECISION`
|
||||
|
||||
## 관련 sub-branch
|
||||
|
||||
- [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]] (부모, P3B Single EC2 + Google federation)
|
||||
- [[raw/branch-notes/feature-keycloak-public-domain-tunneling]] — 학습 환경 public 도메인 확보 (ngrok / Cloudflare Tunnel) **← Cloudflare Tunnel 결정 근거 cross-reference**
|
||||
- [[raw/branch-notes/feature-keycloak-reverse-proxy-headers]] — Keycloak reverse proxy 설정
|
||||
- [[raw/branch-notes/feature-keycloak-https-termination-caddy-nginx]] — HTTPS termination
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기.
|
||||
|
||||
- [ ] **Google Cloud project 생성** — `console.cloud.google.com` → New project → project name 설정 — 등급: `planned`
|
||||
- [ ] **OAuth consent screen 설정** — External user type / app name / support email / app logo (선택) / scopes(`openid`, `email`, `profile`). 이 basic scope 조합은 test-user 등록 불필요 — 등급: `planned`
|
||||
- [ ] **OAuth 2.0 Client ID 생성** — APIs & Services → Credentials → Create Credentials → OAuth client ID → Application type: **Web application** — 등급: `planned`
|
||||
- [ ] **Authorized JavaScript origins 입력** — 본 시나리오에서는 불필요 (Keycloak server-to-server brokering). 명시만 — 등급: `documented-only`
|
||||
- [ ] **Authorized redirect URIs 입력** — `https://<keycloak-domain>/keycloak/realms/<realm>/broker/google/endpoint` (Keycloak Admin에서 자동 생성한 callback URL 그대로 복사) — 등급: `planned`
|
||||
- [ ] **`client_id` + `client_secret` 발급 + Keycloak Admin 입력** — Keycloak Admin Console → Identity Providers → Add provider → Google → Client ID / Client Secret 필드 — 등급: `planned`
|
||||
- [ ] **Verification screen 정책 정리** — 학습용 `openid email profile`은 basic identity scope 예외라 test-user allowlist·100명 상한·7일 만료·unverified 경고가 적용되지 않는다. sensitive/restricted scope 추가 시 별도 verification 정책으로 분기 — 등급: `documented-only`
|
||||
- [ ] **ngrok URL 변경 시 갱신 흐름** — (a) 새 ngrok 세션 시작 → (b) 새 URL 확인 → (c) Google Console → Edit OAuth client → Authorized redirect URIs 갱신 → (d) Keycloak `KC_HOSTNAME` 환경변수 + redeploy → (e) Keycloak Admin Google IdP의 redirect URL 확인 — 등급: `planned`
|
||||
- [ ] **Cloudflare Tunnel 정적 도메인이 burden 제거하는 이유 정리** — 1회 등록 후 영구. 6-1과 cross-reference — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- Keycloak Admin Console에서 IdP alias를 `google`로 설정하면 callback URL이 `.../broker/google/endpoint` 형식으로 발급. alias를 다르게 바꾸면 그에 맞춰 URL도 변경.
|
||||
- Google `client_secret`은 Keycloak DB에 plaintext 저장(또는 vault credentials store) → secret rotation 정책 필요. 학습용은 무시.
|
||||
- Google OAuth 2.0 Client 생성 시 "Authorized JavaScript origins"는 implicit/PKCE flow의 SPA가 직접 Google과 통신할 때만 필요. 본 시나리오는 Keycloak이 server-to-server로 Google `/token` 호출 → JavaScript origins 비워둬도 동작.
|
||||
- Verification screen: External user type + `openid email profile`만 사용하면 basic identity scope 예외로 test-user allowlist 등록 없이 접근할 수 있다. sensitive/restricted scope를 추가할 때만 해당 verification·quota를 별도 검토한다.
|
||||
|
||||
### ngrok URL 변경 시 갱신 절차 (운영 burden 데모)
|
||||
|
||||
| 단계 | 작업 | 소요 |
|
||||
|------|------|------|
|
||||
| 1 | `ngrok http 80` 재시작 → 새 URL 확인 | 즉시 |
|
||||
| 2 | Google Cloud Console → APIs & Services → Credentials → OAuth client 편집 | 1분 |
|
||||
| 3 | Authorized redirect URIs 갱신: `https://<new-ngrok>.ngrok-free.app/keycloak/realms/<realm>/broker/google/endpoint` | 1분 |
|
||||
| 4 | Save → 변경 propagation 대기 (Google docs: 최대 수시간, 보통 즉시) | 0~수시간 |
|
||||
| 5 | Keycloak `KC_HOSTNAME=https://<new-ngrok>.ngrok-free.app` 갱신 후 컨테이너 재시작 | 1분 |
|
||||
| 6 | Keycloak Admin → Identity Providers → Google → callback URL 확인 (자동 갱신) | 즉시 |
|
||||
| 7 | SPA `redirect_uri`가 새 도메인을 가리키는지 확인 (vanilla JS에서는 build/run config 갱신) | 1분 |
|
||||
|
||||
→ 매 세션 5~10분 + propagation 대기. 학습 친화적 X.
|
||||
|
||||
### Cloudflare Tunnel 정적 도메인 대안
|
||||
|
||||
| 단계 | 작업 | 소요 |
|
||||
|------|------|------|
|
||||
| 1 | `cloudflared tunnel run <name>` 시작 → 정적 도메인 사용 | 즉시 |
|
||||
| 2 | Google Console redirect_uri 1회 등록 | 1분 (최초만) |
|
||||
| 3 | 이후 세션 변경에도 redirect_uri 갱신 불필요 | 0 |
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- **2026-05-25**: Google IdP scope는 `openid email profile`만 사용. 이유: sensitive scope 회피 → Google verification 심사 불필요 → 학습 환경에서 unverified test users로 즉시 동작.
|
||||
- **2026-05-25**: Google OAuth client Application type은 **Web application** 채택. 이유: Keycloak이 server-to-server로 `/token` 호출, confidential client (client_secret 사용). SPA에서 직접 Google 호출 안 함 → JavaScript origin 비워둠.
|
||||
- **2026-05-25 (historical, superseded)**: ~~First Broker Login Flow는 부모 P3B "마주친 문제 4번"에 따라 `email_verified=true` hard-reject까지 본 등록 노트에서 정한다.~~
|
||||
- **2026-07-18**: First Broker Login 정책은 [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D1/D4만 consume한다 — AutoLink를 추가하지 않아 silent auto-link를 차단한다. `email_verified=false` 전체 hard-reject는 구현된 custom SPI가 있는 별도 variant로 유보한다.
|
||||
- **2026-05-25**: ngrok 운영 burden을 정량적으로 (`매 세션 5~10분 + propagation 대기`) 기록. 이 데이터가 sub-sub-branch `-6-1`의 Cloudflare Tunnel 우선 결정의 근거가 됨.
|
||||
- **2026-05-25**: 본 sub-sub-branch 전체 등급 `documented-only`. 실 Google Cloud project 생성 / OAuth client 등록은 P3A 완료 후 선택적 확장 시점에 재검토.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 본 sub-sub-branch 는 `documented-only`. cited raw sources: `google-oauth2-redirect-uri-validation-official`, `keycloak-google-idp-setup`, `keycloak-first-login-flow`(D7), `google-oauth2-client-application-types-official`+`google-oauth2-web-server-flow-official`(D6), `google-oauth-manage-app-audience-official`+`google-oauth-app-verification-state-overview-official`(D5). D5·D6·D7 은 `/branch-spec` 자동조사(2026-07-16)로 `UNSUPPORTED_DECISION` → `official-vendor-doc` 승급(단 D6 JS-origins 비움은 구조적 추론, D7 email_verified 강제·D8 정량 수치는 잔여 UNSUPPORTED).
|
||||
|
||||
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|
|
||||
| D1 | Authorized redirect URIs 에 `https://<keycloak-domain>/keycloak/realms/<realm>/broker/google/endpoint` 1개만 정확히 등록 — exact match 요구 | `raw/official-docs/google-oauth2-redirect-uri-validation-official.md#GOOGLE-REDIR-C3` ("The value must exactly match one of the authorized redirect URIs ... If this value doesn't match an authorized URI, you will get a 'redirect_uri_mismatch' error") + `raw/official-docs/keycloak-google-idp-setup.md#KC-GIDP-C3` ("you'll need from this page is the `Redirect URI`. You'll have to provide that to Google when you register Keycloak as a client there") + `raw/official-docs/keycloak-google-idp-setup.md#KC-GIDP-C4` ("copy and paste the `Redirect URI` ... into the `Authorized redirect URIs` field") | `official-vendor-doc` | "exactly match" 의 byte-level 정의 (trailing slash / case / query string) 는 vendor verbatim 부재 — 실험 검증 필요 |
|
||||
| D2 | redirect URI 는 HTTPS scheme 필수 (학습 환경의 localhost 예외 제외) | `raw/official-docs/google-oauth2-redirect-uri-validation-official.md#GOOGLE-REDIR-C1` ("Redirect URIs must use the HTTPS scheme, not plain HTTP. Localhost URIs (including localhost IP address URIs) are exempt from this rule") | `official-vendor-doc` | localhost 예외가 production 시나리오 에서 허용된다는 뜻은 아님 — 학습 단계 한정 |
|
||||
| D3 | redirect URI host 는 raw IP 금지 — public domain 필요 → ngrok/Cloudflare Tunnel 같은 tunneling 도구 채택 정당화 | `raw/official-docs/google-oauth2-redirect-uri-validation-official.md#GOOGLE-REDIR-C2` ("Hosts cannot be raw IP addresses. Localhost IP addresses are exempted from this rule") | `official-vendor-doc` | Cloudflare Tunnel 의 `<UUID>.cfargotunnel.com` 같은 generic subdomain 이 "raw IP 가 아니므로" 항상 허용되는지 vendor 정책 verbatim 부재 |
|
||||
| D4 | redirect URI 에 wildcard / fragment 사용 불가 → 다중 환경 (dev/staging/prod) 각각 별도 등록 | `raw/official-docs/google-oauth2-redirect-uri-validation-official.md#GOOGLE-REDIR-C4` ("Redirect URIs cannot contain the fragment component") + `raw/official-docs/google-oauth2-redirect-uri-validation-official.md#GOOGLE-REDIR-C5` ("Redirect URIs cannot contain certain characters including: Wildcard characters (`'*'`)") | `official-vendor-doc` | 환경 분리 best practice 자체는 cited raw 에 verbatim 없음 — wildcard 금지 결과로 유도된 운영 결정 |
|
||||
| D5 | Google IdP scope 는 `openid email profile` 만 사용 — sensitive scope 회피 → verification 심사 불필요, **그리고 이 basic identity scope 조합은 test-user allowlist 등록·100명 상한·7일 만료·unverified 경고가 모두 면제됨** (기존 본문의 "100명 test users까지" 표현은 부정확 → §마주친 문제 정합 권고 참조) | `raw/official-docs/keycloak-google-idp-setup.md#KC-GIDP-C5` ("By default, Keycloak uses the following scopes: `openid` `profile` `email`") + `raw/official-docs/google-oauth-manage-app-audience-official.md#GOOGLE-APPAUD-C4` ("The only exception ... userinfo.email, userinfo.profile, openid ... your users do not need to be in the trusted user list, they will not see a warning message, and their authorizations will not expire after 7 days") + `raw/official-docs/google-oauth-app-verification-state-overview-official.md#GOOGLE-VERIFY-STATE-C2` ("Exception: If the app only requests basic identity scopes (openid, email, profile), any user can access without being on the allowlist") + `#GOOGLE-VERIFY-STATE-C4` (verification 은 sensitive/restricted scope public 앱에만 "Required for") | `official-vendor-doc` | Published(In production) 전환 시에도 이 예외가 유지되는지(brand verification 별도 요구 여부)는 미확인 — `documented-only`/Testing 고정이라 당장 무영향. Testing 100-user cap(`GOOGLE-APPAUD-C1`)과 unverified-app-screen 신규 100-user cap 은 **서로 다른 quota** — 혼동 금지 |
|
||||
| D6 | Google OAuth client Application type = **Web application** (confidential/server-side client); Keycloak 이 server-to-server `/token` 호출 → JavaScript origins 비워둠 | Application type: `raw/official-docs/google-oauth2-web-server-flow-official.md#GOOGLE-WEBSERVER-C2` ("Select the Web application application type") + confidential flow: `#GOOGLE-WEBSERVER-C1` ("designed for applications that can store confidential information and maintain state") + `raw/official-docs/google-oauth2-client-application-types-official.md#GOOGLE-CLIENTTYPE-C1` ("Private Clients ... can securely store the client secret because they run on servers you control") + `#GOOGLE-CLIENTTYPE-C3` (web application 정의). **JS origins 비움**: `#GOOGLE-CLIENTTYPE-C5` ("Applications that use client-side JavaScript ... must specify authorized JavaScript origins") 의 *조건부 트리거* + web-server-flow 문서가 redirect URIs(`GOOGLE-WEBSERVER-C3`)만 언급하고 JS origins 미언급 → **구조적 추론** (명시적 "비워도 됨" 문장은 vendor 부재) | `official-vendor-doc (Application type/confidential 확정) + official-vendor-doc 구조적 추론 (JS origins 비움 — 명시 아님)` | Google 어떤 공식 문서도 "JavaScript origins 를 비워도 된다"를 *명시적으로* 선언 안 함 — 조건부 스코핑(C5)+web-server 문서 침묵의 추론. `verified` 승급은 실제 Console 등록 실험 후에만(§Claims To Verify). Service-account/native-app 흐름은 본 D6 범위 밖 |
|
||||
| D7 | **DELEGATED** — [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D1/D4 — AutoLink를 추가하지 않고 소유 증명 없는 silent auto-link를 차단 | `raw/official-docs/keycloak-first-login-flow.md#KC-FLF-C2`, `#KC-FLF-C3` | `delegated + official-vendor-doc` | `email_verified=false` 전체 hard-reject는 현재 provider/SPI artifact가 없으므로 본 branch가 보장하지 않는다. 필요한 경우 별도 custom SPI variant에서 구현·검증 후 owner를 연결한다. |
|
||||
| D8 | ngrok 운영 burden (매 세션 5~10분 갱신) → sub-sub-branch `-6-1` 의 Cloudflare Tunnel 정적 도메인 채택 정당화 | `raw/official-docs/google-oauth2-redirect-uri-validation-official.md#GOOGLE-REDIR-C3` (exact match 요구로 인해 URL 변경 시 매번 갱신 필요) — 간접 근거. **"5~10분" 정량 수치** 는 작성자 운영 추정 (`UNSUPPORTED_DECISION` — verbatim 외부 출처 없음) | `official-vendor-doc (exact match 배경) + UNSUPPORTED_DECISION (정량 수치)` | "5~10분" 수치를 실제 측정으로 대체 (P3B 구현 시점) 또는 작성자 추정임을 명시 유지 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 본 sub-sub-branch 는 `documented-only` — 여기서 "구현"은 코드가 아니라 **Google Cloud Console + Keycloak Admin 등록 절차의 사전 명세**다. 실 등록을 수행할 미래 작업자가 되묻지 않고 필드를 채울 수 있는 수준이 목표.
|
||||
> 3-rule: **R1** 각 row 는 Decision ID + Claim ID reference · **R2** 근거 없는 detail 은 `UNSUPPORTED_IMPL_DECISION` + trade-off 한 줄 · **R3** 본 branch 결정 범위 밖(부모 proxy/hostname 설정 · sibling flow authenticator · tunnel 설정)은 §엣지·실패·의존 으로 위임(여기 재진술 안 함).
|
||||
|
||||
### 1. Google Cloud Console — OAuth 2.0 Client ID 등록 필드 명세
|
||||
|
||||
> **Trace**: D1·D2·D3·D4 (`GOOGLE-REDIR-C1`~`C5`) redirect URI 정책 · D5 (`KC-GIDP-C5` + `GOOGLE-APPAUD-C4` + `GOOGLE-VERIFY-STATE-C2`) scope+verification · D6 (`GOOGLE-WEBSERVER-C1/C2` + `GOOGLE-CLIENTTYPE-C1/C3/C5`) client type/JS origins.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (a) `Name` 표시값 = 임의(동작 무관, trade-off: 학습 단계 무영향). (b)·(c) 는 `/branch-spec` 자동조사(2026-07-16)로 해소 — JS origins 비움은 `GOOGLE-CLIENTTYPE-C5` 조건부 트리거의 **구조적 추론**(명시적 "비워도 됨" vendor 부재 → D6 Open Risk 유지), test-user 정책은 `GOOGLE-APPAUD-C4` 예외로 **정정**(basic scope 조합엔 100명 한도 부적용).
|
||||
|
||||
| 필드 | 입력 값 | Trace | Note |
|
||||
|---|---|---|---|
|
||||
| Application type | **Web application** | D6 (`GOOGLE-WEBSERVER-C2`, `GOOGLE-CLIENTTYPE-C1/C3`) | confidential(server-to-server) client — client_secret 서버 보관 |
|
||||
| Name | 임의 (예: `keycloak-broker-learning`) | — | `UNSUPPORTED_IMPL_DECISION` — 표시 이름, 동작 무관 |
|
||||
| Authorized redirect URIs | `https://<keycloak-domain>/keycloak/realms/<realm>/broker/google/endpoint` | D1 (`GOOGLE-REDIR-C3`) + `KC-GIDP-C4` | 실제 SSOT = Keycloak Admin "Redirect URI" 표시값(`KC-GIDP-C3`). `/keycloak`=부모 D4, `<realm>`/`google` alias=프로젝트 값(§엣지·실패·의존 위임) |
|
||||
| — scheme | HTTPS 필수 | D2 (`GOOGLE-REDIR-C1`) | localhost 만 예외 → 학습도 tunnel HTTPS 사용 |
|
||||
| — host | raw IP 금지 → tunnel 도메인 | D3 (`GOOGLE-REDIR-C2`) | cfargotunnel.com 통과 여부 = Claims To Verify |
|
||||
| — 제약 | wildcard(`*`)·fragment(`#`) 불가 | D4 (`GOOGLE-REDIR-C4`,`C5`) | dev/staging/prod 각각 별도 등록 |
|
||||
| Authorized JavaScript origins | (비움) | D6 (`GOOGLE-CLIENTTYPE-C5`) | client-side JS 미사용 → 구조적 추론상 불필요(명시적 vendor 문장 부재 → D6 Open Risk). `verified` 는 Console 실험 후(§Claims To Verify) |
|
||||
| Consent screen — User type | External | D5 | |
|
||||
| Consent screen — Scopes | `openid` `email` `profile` | D5 (`KC-GIDP-C5`, `GOOGLE-VERIFY-STATE-C4`) | non-sensitive → verification 회피(공식 근거 확보) |
|
||||
| Consent screen — Test users | (등록 불필요) | D5 (`GOOGLE-APPAUD-C4`) | ⚠️ 정정: basic scope 조합은 test-user allowlist·100명 상한·7일 만료·경고 모두 면제 — "100명 한도까지 동작" 표현은 부정확 |
|
||||
|
||||
### 2. Keycloak Admin Console — Google IdP 입력 매핑
|
||||
|
||||
> **Trace**: D1 + `KC-GIDP-C1`~`C5`. 양방향 등록(Keycloak Redirect URI → Google, Google client_id/secret → Keycloak).
|
||||
|
||||
| 단계 | 위치 | 입력/취득 | Trace |
|
||||
|---|---|---|---|
|
||||
| IdP 추가 | Identity Providers → Add provider → **Google** | alias=`google` | `KC-GIDP-C1` |
|
||||
| Redirect URI 취득 | Add Identity Provider 페이지 `Redirect URI` 표시값 | §1 Authorized redirect URIs 의 SSOT — 이 값을 Google 에 복사 | `KC-GIDP-C3`,`C4` |
|
||||
| Client ID/Secret 입력 | 같은 페이지 `Client ID` / `Client Secret` 필드 | Google 발급값 | `KC-GIDP-C2` |
|
||||
| Default Scopes | Advanced → Default Scopes | `openid profile email`(기본값 유지) | `KC-GIDP-C5` |
|
||||
|
||||
### 3. URL 변경 시 redirect_uri 갱신 절차
|
||||
|
||||
> **Trace**: D8 (`GOOGLE-REDIR-C3` exact match → URL 변경 시 재등록 필수). 절차 표는 §진행 중 메모 "ngrok URL 변경 시 갱신 절차" + "Cloudflare Tunnel 정적 도메인 대안" 이 owner — Single-Owner 원칙상 **여기서 재진술하지 않는다**. tunnel 도구 채택 결정 자체는 sibling [[raw/branch-notes/feature-keycloak-public-domain-tunneling]] D1/D2 소유(§엣지·실패·의존 위임).
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4 캡처용. 정상 등록 경로 외의 실패/엣지 + 본 branch 가 consume 하는 다른 계약. 실 적용 전이므로 "예상" 경로.
|
||||
|
||||
**실패·엣지 경로:**
|
||||
|
||||
- **redirect_uri exact match 위반** (D1): trailing slash 유무 / scheme 누락(http) / relative path 오타(`/keycloak` 누락) / alias 불일치 → Google `redirect_uri_mismatch` → 인증 차단. byte-level 정의(trailing slash·case·query)는 미확정 → Claims To Verify.
|
||||
- **propagation lag** (D1·D8): Google Console redirect_uri 변경 후 즉시~수분 지연 → 학습 시 디버깅 noise. 기대 동작: 재시도/대기.
|
||||
- **generic subdomain 거부 가능성** (D3): `<UUID>.cfargotunnel.com` 이 "no raw IP" 정책은 통과하나 Google 이 별도 사유로 거부할 여지 → 미검증(Claims To Verify; sibling tunneling D1 의 "Does not prove" 단서와 동일 미해소).
|
||||
- **email auto-link 보안 위험** (D7): OOTB 기본은 Confirm Link이며 AutoLink는 별도 opt-in이다. 기대 동작: [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D1/D4에 따라 AutoLink를 추가하지 않아 silent link를 차단한다.
|
||||
- **client_secret 노출** (진행 중 메모): Keycloak DB plaintext 저장 + docker-compose env 노출 → git 커밋 누출 위험. 기대 동작: vault/secret 관리(학습은 무시) → Claims To Verify.
|
||||
- **scope 확장으로 verification 조건 진입** (D5): basic identity scope에는 test-user 한도가 적용되지 않는다. sensitive/restricted scope를 추가하면 별도 verification·quota 조건으로 진입할 수 있으므로 학습 흐름은 `openid email profile`로 고정한다.
|
||||
|
||||
**다른 계약 의존 (consume):**
|
||||
|
||||
- [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]] `D6` — broker endpoint URL 포맷(`.../broker/google/endpoint`)을 consume. 이 URL 이 곧 §구현가이드 §1 Authorized redirect URIs 값. 부모 계약 변경 시 본 branch 재등록 필요.
|
||||
- [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]] `D4` — `KC_HOSTNAME` + `KC_HTTP_RELATIVE_PATH=/keycloak` 를 consume. redirect_uri 의 host·path 가 여기서 결정됨. proxy/hostname 설정은 부모 owner — 본 branch 는 결과 URL 만 사용.
|
||||
- [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D1/D4 — AutoLink 미사용과 silent auto-link 차단을 consume한다. hard-reject는 본 branch 범위가 아니다.
|
||||
- [[raw/branch-notes/feature-keycloak-public-domain-tunneling]] D1/D2 — public 도메인 확보 수단을 consume한다. D8의 갱신 burden 비교가 이 tunnel 채택 결정에 종속하며 설정 detail은 sibling owner다.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| Keycloak callback URL 의 정확한 path 형식 `<keycloak-domain><relative-path>/realms/<realm>/broker/<idp-alias>/endpoint` 이 모든 Keycloak 버전에서 동일 | path 형식의 vendor verbatim 부재; `KC_HTTP_RELATIVE_PATH` 조합 시 정확한 결과의 verbatim 없음 | Keycloak 25.x docker 실행 + admin UI 의 IdP "Redirect URI" 자동 표시값 캡처 + Server Admin Guide raw 발췌 | `needs-confirmation` |
|
||||
| "exactly match" 의 byte-level 정의 (trailing slash / case sensitivity / query string) | cited GOOGLE-REDIR-C3 에 디테일 명시 없음 — "일반 OAuth 관례" 추정 | trailing slash 유무로 등록 후 실제 redirect 시 Google 응답 차이 실험 | `needs-confirmation` |
|
||||
| Cloudflare Tunnel `<UUID>.cfargotunnel.com` generic subdomain 이 Google "no raw IP" 정책 통과 | cited GOOGLE-REDIR-C2 의 "raw IP 금지" 가 generic subdomain 도 cover 하는지 verbatim 부재 | Cloudflare Tunnel 정적 도메인 등록 + Google Console 등록 시도 → propagation 결과 확인 | `planned` |
|
||||
| ngrok URL 변경 시 Google Console propagation 시간 (vendor docs "최대 수시간") | cited raw 에 verbatim 없음 | Google Cloud Console "OAuth 2.0 settings propagation" 공식 페이지 raw 추가 | `needs-confirmation` |
|
||||
| unverified(Testing) app + `openid email profile` (non-sensitive scope) 만 사용 시 test-user 등록 없이 임의 Google 계정 정상 동작 (100명 한도 개념 부적용) | **공식 근거 확보**(`GOOGLE-APPAUD-C4` + `GOOGLE-VERIFY-STATE-C2` — test-user allowlist·100명·7일·경고 모두 면제). 잔여 불확실 = Published(In production) 전환 시 brand verification 별도 요구 여부만 | 실제 Testing app 으로 등록 후 임의 Google 계정 로그인 동작 실측 (문서 근거는 완료) | `needs-confirmation (문서 근거 확보, 실측 미실시)` |
|
||||
| Keycloak `client_secret` plaintext 저장 (또는 vault credentials store) 동작 — git 커밋 누출 위험 | cited raw 에 verbatim 없음 | Keycloak Server Admin Guide "Vault" 섹션 raw 추가 + Keycloak DB 의 `client_secret` 컬럼 확인 | `needs-confirmation` |
|
||||
| `Authorized JavaScript origins` 가 server-to-server brokering 시나리오에서 정말 비워둘 수 있음 | **구조적 근거 확보**(`GOOGLE-CLIENTTYPE-C5` 조건부 트리거 "client-side JS 사용 시에만 필수" + `GOOGLE-WEBSERVER` 문서의 JS origins 미언급) — 단 "비워도 됨" **명시 문장은 vendor 부재**(추론) | origins 비운 상태로 Keycloak ↔ Google `/token` 호출 정상 동작 실측 (구조적 근거는 완료) | `needs-confirmation (구조적 근거 확보, 실측 미실시)` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 아직 없음 (문서 단계). 실 적용 시 예상되는 함정:
|
||||
- **redirect_uri exact match 위반**: trailing slash 유무 / scheme 누락 / relative path 오타. Google docs는 "must match exactly".
|
||||
- **propagation lag**: Google 측 redirect_uri 변경 후 즉시 반영되지만 캐시 영향으로 수분 지연 사례 보고됨. 학습 시 디버깅 noise.
|
||||
- **First Broker Login email match AutoLink**: OOTB 기본이 아니라 별도 opt-in이며, 활성화하면 보안 위험이 생긴다. 현재 정책은 [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D1에 따라 추가하지 않는다.
|
||||
- **`client_secret` 노출**: Keycloak DB에 plaintext 저장. Git 커밋 / docker-compose env file 노출 위험.
|
||||
|
||||
> **정합 권고 (`/branch-spec` 자동조사 2026-07-16 — 사용자 본문 verbatim 미변경, 정정만 surface):**
|
||||
> **정합 반영 완료 (2026-07-18)**: `목표/WHY`·`TODO`·`진행 중 메모`의 옛 "100명 test users까지" 문구를 basic identity scope 예외로 갱신했다. `openid`/`email`/`profile`만 요청하면 test-user allowlist·100명 상한·7일 만료·unverified 경고가 면제된다(`GOOGLE-APPAUD-C4`, `GOOGLE-VERIFY-STATE-C2`). sensitive/restricted scope의 quota는 별도 조건이다.
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/google-oauth-app-verification-state-overview-official]]
|
||||
- [[raw/official-docs/google-oauth-manage-app-audience-official]]
|
||||
- [[raw/official-docs/google-oauth2-client-application-types-official]]
|
||||
- [[raw/official-docs/google-oauth2-redirect-uri-validation-official]]
|
||||
- [[raw/official-docs/google-oauth2-web-server-flow-official]]
|
||||
- [[raw/official-docs/keycloak-google-idp-setup]]
|
||||
- [[raw/official-docs/keycloak-identity-brokering-overview-official]]
|
||||
- [[raw/official-docs/oauth-v2-1-draft-ietf]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
> 본 branch 는 leaf — 자식 자료 없음. errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 근거 자료
|
||||
|
||||
> 전체 목록·정당화 결정 매핑은 상단 "## Sources / 근거" 섹션이 owner (Single-Owner, 중복 재진술 안 함). 최근 추가: [[raw/official-docs/google-oauth-app-verification-state-overview-official]] — D5 verification-policy 부분 developer-doc 근거 보강 (2026-07-16).
|
||||
|
||||
### 오류 기록
|
||||
|
||||
- (없음)
|
||||
|
||||
### 면접 준비
|
||||
|
||||
- (없음)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 본 sub-sub-branch는 **문서까지만**. 실 Google Cloud project / OAuth client 등록은 P3A 완료 후 선택적 확장.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: 해당 없음 (문서 단계, P3B 전체 `documented-only`)
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목: 없음
|
||||
- `locally-verified` 항목: 없음
|
||||
- `prod-verified` 항목: 없음
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
- 본 sub-sub-branch 전체가 `documented-only`. 추후 `wiki/concepts/keycloak-deployment-patterns.md` 합성 시 "Google IdP 등록 + redirect_uri exact match" 섹션으로 인용 후보.
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
---
|
||||
title: branch / feature-keycloak-header-spoofing-defense (P1A — 헤더 spoofing 방어, Network 경계 강제)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-PATTERNS-OVERVIEW-014
|
||||
kind: project-work-item
|
||||
project: keycloak-patterns-overview
|
||||
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-014
|
||||
inherits: [DEC-KEYCLOAK-PATTERNS-OVERVIEW-EDGE-FORWARDAUTH-001@1, DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-KEYCLOAK-PATTERNS-OVERVIEW-012]
|
||||
contract_packet: 1
|
||||
branch: feature-keycloak-header-spoofing-defense
|
||||
parent_branch:
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, p1a, network-policy, security-group, header-spoofing, zero-trust]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 2bdbf4639b4e61ad38f9f32504f877dd87ad84f8f07523fd0c874bc316c1ae79
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-header-spoofing-defense (P1A — 헤더 spoofing 방어, Network 경계 강제)
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/project-notes/keycloak-patterns-overview]]의 `WI-KEYCLOAK-PATTERNS-OVERVIEW-014` 직접 branch.
|
||||
> P1A 패턴이 깨지는 **유일하고도 가장 흔한 경로** — backend가 ingress 우회 경로로 도달 가능할 때 — 의 방어 메커니즘을 정리. K8s `NetworkPolicy`, EC2 Security Group, mTLS, shared-secret 헤더 검증 4가지를 비교.
|
||||
> 본 sub-sub-branch는 **문서까지만** (`documented-only`).
|
||||
> `status_label`: `in-progress`
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/project-notes/keycloak-patterns-overview]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: forwarded-user spoofing 우회를 재현하고 network isolation 후 차단한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-EDGE-FORWARDAUTH-001@1` | AP4는 oauth2-proxy ForwardAuth를 사용한다 | forwarded identity header의 신뢰 경계와 network isolation에 적용한다 | [[raw/project-notes/keycloak-patterns-overview]] |
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1` | done-bar는 E2E success와 signature security failure 재현·해결 evidence다 | spoofing 우회 재현과 차단 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 -->
|
||||
## 목표
|
||||
|
||||
P1A의 단점 섹션(부모 sub-branch §장점/단점)에서 가장 먼저 등장하는 문제는 **헤더 spoofing**이다. backend는 `X-Auth-Request-User: alice` 헤더를 oauth2-proxy가 붙였다고 **믿고만** 동작하므로, ingress를 우회해 backend에 직접 접근할 수 있는 경로가 하나라도 있으면 패턴 전체가 무너진다.
|
||||
|
||||
본 sub-sub는 이 단일 위협에 대해:
|
||||
1. **K8s 환경**: `NetworkPolicy`로 ingress namespace의 pod만 backend pod에 in-bound 허용.
|
||||
2. **EC2/VM 환경**: Security Group inbound를 ALB/ingress SG만 허용. backend가 0.0.0.0에 listen하지 않게.
|
||||
3. **mTLS 옵션**: ingress ↔ backend 간 mutual TLS로 헤더 발신자를 cryptographic하게 검증.
|
||||
4. **헤더 검증 추가**: oauth2-proxy ↔ backend가 공유하는 shared secret을 별도 헤더(`X-Internal-Auth-Token`)에 실어 backend가 검증.
|
||||
|
||||
이 네 가지 trade-off와 각각의 운영 비용을 정리.
|
||||
|
||||
핵심 질문:
|
||||
1. K8s `NetworkPolicy`는 default-deny + ingress namespace allow 두 단계로 작성해야 한다. 왜?
|
||||
2. EC2 Security Group만으로 충분한가? VPC 내부 다른 인스턴스의 위협은?
|
||||
3. mTLS는 왜 ForwardAuth 패턴에서 자주 생략되는가? (운영 복잡도 vs 위협 모델)
|
||||
4. shared-secret 헤더는 어디에 저장하고 어떻게 회전하는가?
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- 4가지 헤더 spoofing 방어 메커니즘의 **비교·근거 문서화** (`documented-only`): K8s NetworkPolicy 2단계(D3), EC2 Security Group + loopback bind 2계층(D4), mTLS deferral 조건(D2), shared-secret 헤더(D5).
|
||||
- 각 메커니즘의 공식 vendor doc 근거 + 명시적 실패 모드 정리 (§Decision Evidence Map, §구현 가이드, §엣지·실패·의존).
|
||||
- 면접 답변 후보: "P1A/AP4 패턴에서 가장 중요한 운영 결정 = 백엔드를 ingress 뒤에 네트워크로 격리하는 강제 메커니즘"(D1).
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외. 면접에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- **실 구현 / E2E 시연** — 본 note 는 `documented-only`. 실 방어 구성·시연은 P3A 실 구현 단계(프로젝트 SSOT §5).
|
||||
- **mTLS 실 구성** (D2) — 프로젝트 SSOT §5 에서 project-level out-of-scope. 본 note 는 "왜 defer 하는가"의 조건만 문서화.
|
||||
- **K8s 클러스터 실 구축** — 프로젝트는 single-EC2 실 구현(SSOT §F5), cluster-internal 은 §2.2 cross-cutting 문서만. NetworkPolicy 는 원칙 문서화만.
|
||||
- **Detection 계층** (VPC Flow Logs / GuardDuty 등 사후 탐지) — prevention 이 아니므로 본 결정 축 밖.
|
||||
- **IAM 최소권한** (SG 수정 권한 scoping) — SG 방어를 우회할 수 있는 control-plane 위협이나 네트워크 결정과 독립된 별도 관심사.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 부모 sub-branch에서 인용한 자료 + 본 sub-sub에서 추가 검토 후보.
|
||||
|
||||
- [[raw/official-docs/oauth2-proxy-nginx-integration-official]] — ingress 우회 위험을 명시한 oauth2-proxy 공식 가이드 (D1 근거, 부모 인용 재참조)
|
||||
- [[raw/official-docs/keycloak-reverseproxy-official]] — Keycloak reverse proxy 환경의 header spoofing 공식 경고(KC-RP-C3) + `KC_PROXY_TRUSTED_ADDRESSES` 예시(KC-RP-C5) (D1·D6 근거)
|
||||
- [[raw/official-docs/traefik-forwardauth-middleware-official]] — Traefik ForwardAuth `authResponseHeaders` replace 동작(TFA-C3) + `trustForwardHeader` deprecated 경고(TFA-C6) (D1·D7 근거)
|
||||
- [[raw/official-docs/aws-security-group-referencing-official]] — AWS 공식: SG-source rule 은 그 SG 소속 인스턴스만 대상·private IP 통신(AWS-SG-REF-C1), same-VPC/peering/TGW 범위 조건(AWS-SG-REF-C2), multi-SG aggregation=union(AWS-SG-REF-C3) (D4 SG-reference 동작·범위 근거)
|
||||
- [[raw/official-docs/aws-alb-target-security-group-restriction-official]] — AWS 공식: target(EC2 instance) 의 security group 을 load balancer 의 security group 만 허용하도록 제한하라는 권고 (D4 SG 제한 부분의 근거)
|
||||
- [[raw/official-docs/docker-port-publishing-loopback-bind-official]] — Docker Engine 공식: host IP 미지정 시 기본적으로 모든 host 주소(`0.0.0.0`/`[::]`)에 publish 하는 것이 "insecure by default", publish flag 에 `127.0.0.1`/`::1` 을 포함하면 Docker host 로만 접근 범위가 좁혀짐 (D4 listen-address 제한 부분의 근거)
|
||||
- [[raw/official-docs/k8s-network-policy-official]] — Kubernetes NetworkPolicy 공식: pod 기본 non-isolated → NetworkPolicy 가 selecting 시 isolated (KNP-C1), policy additive/union 의미론 (KNP-C2), CNI 미구현 시 no effect — silent no-op 위험 (KNP-C3). D3(K8s NetworkPolicy default-deny + ingress namespace allow 2단계 작성) 근거로 2026-07-16 raw 보존 완료
|
||||
- [[raw/official-docs/aws-cloudfront-origin-shared-secret-header-official]] — AWS CloudFront→ALB shared-secret custom header 공식 mitigation: 헤더를 secure credential 로 취급 (CF-ALB-SECRET-C2), secret 유출 시 전면 우회되는 명시적 실패 모드 (CF-ALB-SECRET-C3), network-layer(AWS-managed prefix list) 병행 권고 (CF-ALB-SECRET-C4), make-before-break 회전 절차 (CF-ALB-SECRET-C5). D5(shared-secret 헤더 = defense-in-depth 2차, network 격리가 1차) 근거로 2026-07-16 raw 보존 완료
|
||||
- [[raw/official-docs/istio-mtls-cert-rotation-official]] — Istio 공식: mTLS 채택 시 key management 시스템이 cert 생성·배포·rotation 을 자동화해야 함(ISTIO-MTLS-C1/C2/C3), mTLS handshake 의 secure naming check 가 발신자를 암호학적으로 인증(ISTIO-MTLS-C4/C5). D2(학습 프로젝트 한정 mTLS out-of-scope)의 "운영 비용 = cert lifecycle" 기술적 전제 근거로 2026-07-16 raw 보존 완료
|
||||
- (검토 후보) Calico NetworkPolicy 공식 — 본 sub-sub 진행 시 raw 추가 여부 결정
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기.
|
||||
|
||||
- [ ] K8s `NetworkPolicy` 예제 작성 (default-deny ingress + ingress namespace allow + DNS egress 허용) — 등급: `planned` — 근거: `[[raw/official-docs/k8s-network-policy-official]]#KNP-C1`(selecting 시 isolated), `#KNP-C2`(additive/union — 두 리소스로 나눠 써도 안전)
|
||||
- [ ] `NetworkPolicy`의 CNI 의존성 정리 (Calico / Cilium 등이 지원해야 동작. flannel default는 enforce 안 함) — 등급: `planned` — 근거: `[[raw/official-docs/k8s-network-policy-official]]#KNP-C3`(CNI 미구현 시 no effect, 어떤 CNI 가 구현하는지는 does-not-prove)
|
||||
- [ ] EC2 Security Group inbound 예제: backend SG는 ALB SG만 허용. SSH/관리 포트는 별도 bastion SG — 등급: `planned`
|
||||
- [ ] backend listen address를 `0.0.0.0:8080`이 아닌 `127.0.0.1:8080` + sidecar proxy 또는 private subnet 한정 — 등급: `planned`
|
||||
- [ ] mTLS 옵션 비교: ingress ↔ backend 간 TLS client cert 검증. cert 발급/회전 비용 정리 — 등급: `planned`
|
||||
- [ ] mTLS 미사용 사유 정리 (학습 프로젝트 한정, 운영 복잡도 > 위협 모델) — 등급: `planned`
|
||||
- [ ] shared-secret 헤더 검증 패턴: `X-Internal-Auth-Token: <hmac>` + backend middleware 검증. 회전 정책 — 등급: `planned`
|
||||
- [x] 4가지 방어책 비교표 (운영 비용 / 보안 강도 / 도입 시점) — §구현 가이드 §0 에 작성 완료 (2026-07-16) — 등급: `documented-only`
|
||||
- [ ] 면접 답변 후보 정리: "P1A 패턴에서 가장 중요한 운영 결정은?" → "백엔드가 ingress 외 경로로 도달되지 않도록 네트워크 격리를 강제하는 것" — 등급: `planned`
|
||||
- [x] K8s NetworkPolicy 공식 raw 보존 검토 ([[raw/official-docs/k8s-network-policy-official]] — 2026-07-16 완료, KNP-C1/C2/C3 추출) — 등급: `actually-implemented` (raw 보존 자체)
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
> 작업하며 떠오른 메모.
|
||||
|
||||
- ForwardAuth 패턴의 위협 모델 핵심: **backend는 헤더만 보는 trust-on-message**. 메시지 발신자 검증이 네트워크 레이어에 위임된다.
|
||||
- K8s NetworkPolicy는 CNI 미지원이면 manifest만 있고 enforce가 안 되는 silent failure 위험 있음. `kubectl get networkpolicy`만으로는 enforcement 여부 알 수 없음.
|
||||
- shared-secret 헤더는 spoofing 방어로는 약함 (헤더 자체가 leak되면 끝). 네트워크 격리가 1차, shared-secret은 defense-in-depth 2차 정도로 정리.
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- **2026-05-25 (decision candidate)**: **P1A 패턴의 가장 중요한 운영 결정 = 백엔드를 ingress 뒤에 격리하는 강제 메커니즘**. 본 sub-sub의 결론은 면접/포트폴리오 답변에서 P1A를 설명할 때의 핵심 메시지로 채택.
|
||||
- **2026-05-25**: 학습 프로젝트 한정으로 mTLS는 out of scope. 운영 환경 가정 시 검토 항목으로만 표기.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. 본 sub-sub 의 4가지 방어책 중 Traefik `trustForwardHeader` deprecated 경고, Keycloak `KC_PROXY_TRUSTED_ADDRESSES`, EC2 Security Group(D4), K8s NetworkPolicy(D3, 2026-07-16 [[raw/official-docs/k8s-network-policy-official]] 보존 후), shared-secret 헤더(D5, 2026-07-16 [[raw/official-docs/aws-cloudfront-origin-shared-secret-header-official]] 보존 후 — CloudFront→ALB 구조적 동형 패턴 인용) 는 vendor doc 으로 직접 뒷받침. mTLS(D2) 만 여전히 UNSUPPORTED_DECISION.
|
||||
|
||||
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|
|
||||
| D1 | P1A 패턴의 가장 중요한 운영 결정 = 백엔드를 ingress 뒤에 격리하는 강제 메커니즘 (네트워크 경계가 1차 방어, 헤더 검증은 trust-on-message) | `raw/official-docs/traefik-forwardauth-middleware-official.md#TFA-C3` (`authResponseHeaders` 의 replace 동작이 client spoof 를 강제로 deny 하지 않음 — does-not-prove), `raw/official-docs/oauth2-proxy-nginx-integration-official.md#O2PN-C3` (X-User 헤더 신뢰가 안전하다는 뜻 아님 — does-not-prove), `raw/official-docs/keycloak-reverseproxy-official.md#KC-RP-C3` (proxy header spoofing 공식 경고) | `official-vendor-doc` (3개 공식 문서가 모두 "헤더 검증만으로는 부족" 을 명시) | 면접 답변으로 채택했으나, 학습 프로젝트 자체는 단일 EC2 단독 운영이라 실제 네트워크 격리 시연 부재 |
|
||||
| D2 | 학습 프로젝트 한정으로 mTLS 는 out of scope (운영 환경 가정 시 검토 항목) | `raw/official-docs/istio-mtls-cert-rotation-official.md#ISTIO-MTLS-C1` (mTLS 채택 시 cert 생성·배포·rotation 을 자동화하는 key management 시스템이 필요 — 운영 비용의 실체가 "cert lifecycle 관리"), `#ISTIO-MTLS-C2`(rotation 이 자동·주기적으로 발생해야 함 — 수동 관리가 아니라 자동화 인프라 자체가 전제), `#ISTIO-MTLS-C4`(mTLS 는 secure naming check 로 발신자를 암호학적으로 인증 — mTLS 의 강점 자체는 공식 근거 확보) + **UNSUPPORTED_DECISION 잔존**: "이미 mesh 가 없으면 그 비용이 정당화되지 않는다"는 결론 자체는 Istio 문서가 직접 말하지 않음 — 이 프로젝트가 단일 EC2 라는 전제와 결합한 사용자 trade-off 판단 | `official-vendor-doc`(mTLS 운영 비용의 기술적 전제) + `UNSUPPORTED_DECISION`(mesh 부재 시 defer 하는 결론 자체) | 학습 단계 deferral 이 운영 단계에서 누락될 위험. mesh 신규 도입 비용과 mTLS 를 mesh 없이 수동 구성하는 비용의 정량 비교는 여전히 미실측 |
|
||||
| D3 | K8s 환경: `NetworkPolicy` default-deny + ingress namespace allow 2단계 작성 | `raw/official-docs/k8s-network-policy-official.md#KNP-C1` (pod 는 기본 non-isolated, selecting 하는 NetworkPolicy 가 있어야 isolated 시작 — default-deny 가 먼저 필요한 이유), `#KNP-C2` (policy 는 additive/union 의미론 — default-deny 와 ingress-namespace-allow 를 별도 두 리소스로 나눠 작성해도 안전하게 합쳐짐), `#KNP-C3` (CNI 가 NetworkPolicy 를 구현하지 않으면 리소스 생성이 no effect — silent no-op 위험, does-not-prove: 어떤 CNI 가 구현하는지 목록) | `official-standard` (Kubernetes 공식 concepts 문서, 3개 claim 모두 원문 verbatim) | NetworkPolicy 가 CNI 미지원 환경 (flannel default) 에서 silent failure → spoofing 방어 실패 (KNP-C3 로 공식 근거 확보됐으나, 실제 클러스터의 CNI 가 NetworkPolicy 를 구현하는지는 실측 필요 — Claims To Verify 참조) |
|
||||
| D4 | EC2/VM 환경: Security Group inbound 를 ALB/ingress SG 만 허용 + backend 가 `0.0.0.0` 가 아닌 `127.0.0.1:8080` listen 또는 private subnet 한정 | `raw/official-docs/aws-security-group-referencing-official.md#AWS-SG-REF-C1` (SG-source rule 은 그 SG 에 연결된 인스턴스만 대상, private IP 로 통신), `#AWS-SG-REF-C2` (SG-reference 는 same-VPC/peering/(inbound 한정)transit-gateway 범위에서만 동작 — CIDR-source 와 달리 범위 제약), `#AWS-SG-REF-C3` (multi-SG aggregation=union — does-not-prove: leftover broad CIDR allow rule 이 함께 aggregate 되면 SG-narrow rule 이 무력화될 수 있다는 문장은 원문에 없고 논리적 추론), `raw/official-docs/docker-port-publishing-loopback-bind-official.md#DOCKER-PORT-PUB-C1` (host IP 미지정 시 Docker daemon 이 기본적으로 `0.0.0.0`/`[::]` 전체에 publish), `#DOCKER-PORT-PUB-C3` (이 기본 동작이 "insecure by default" — 공식 경고), `#DOCKER-PORT-PUB-C4` (publish flag 에 `127.0.0.1`/`::1` 을 포함하면 오직 Docker host 만 접근 가능해짐 — listen address 를 `127.0.0.1` 로 제한하는 부분의 공식 근거) | `official-vendor-doc` (SG-reference 의 scope·동작과 backend listen address 를 `127.0.0.1` 로 제한하는 부분 모두 이제 공식 근거 확보. 단 SG 절반의 "ALB SG 만 허용" 표현은 이 branch 의 실제 토폴로지가 ALB 없는 단일 EC2 라는 점에서 `aws-alb-target-security-group-restriction-official.md` 자신이 "적용될 실제 ALB 가 없다"고 명시 — 원칙만 차용) | VPC 내부 다른 인스턴스의 lateral movement 는 SG-reference 로 이론상 차단되나, 같은 인스턴스에 연결된 **다른** SG 에 broad CIDR allow rule 이 남아있으면 aggregation(union) 으로 인해 무력화될 수 있음 — 실 환경에서 leftover rule 부재 여부 실측 필요. loopback bind(`127.0.0.1:8080`) 절반도 실제 `docker-compose.yml` 적용 후 host 외부에서 curl 실패·host 내부에서 curl 성공 실측 필요 (Claims To Verify 항목 참조) |
|
||||
| D5 | shared-secret 헤더 (`X-Internal-Auth-Token: <hmac>`) 는 defense-in-depth 2차 — 1차 방어는 네트워크 격리 | `raw/official-docs/aws-cloudfront-origin-shared-secret-header-official.md#CF-ALB-SECRET-C2` (헤더 이름/값을 secure credential 로 취급), `#CF-ALB-SECRET-C3` (헤더 secret 유출 = 전면 우회되는 명시적 실패 모드), `#CF-ALB-SECRET-C4` (network-layer prefix list 병행 권고 — header 검증 단독 불충분을 AWS 스스로 인정), `#CF-ALB-SECRET-C5` (make-before-break 회전 절차) | `official-vendor-doc` (CloudFront→ALB 맥락의 구조적 동형 패턴 — 직접 Keycloak/oauth2-proxy 문서는 아니므로 구조 유사성 인용) | 헤더 자체가 leak 되면 우회 가능 (C3 이 명시). 회전 절차의 정확한 "주기"(수치)는 인용 범위 밖 — 본 프로젝트의 실제 회전 주기는 별도 결정 필요 |
|
||||
| D6 | Keycloak reverse proxy 환경에서 `KC_PROXY_TRUSTED_ADDRESSES` 화이트리스트로 proxy header 송신 IP 제한 (단일 EC2 = `127.0.0.1`) | `raw/official-docs/keycloak-reverseproxy-official.md#KC-RP-C5` (`--proxy-trusted-addresses=192.168.0.32,127.0.0.0/8` 예시), `raw/official-docs/keycloak-reverseproxy-official.md#KC-RP-C3` (header spoofing 공식 경고) | `official-vendor-doc` | 화이트리스트 외 IP 가 proxy 헤더 emit 시의 정확한 동작 (drop/ignore/log) 은 인용 범위 밖 (`KC-RP-C5` does-not-prove) |
|
||||
| D7 | Traefik 사용 시 `trustForwardHeader=true` 회피 (deprecated marker — `X-Forwarded-*` 무조건 신뢰 위험) | `raw/official-docs/traefik-forwardauth-middleware-official.md#TFA-C6` | `official-vendor-doc` | 대체 옵션의 정확한 신규 이름은 본 인용에 없음 — 별도 deprecated 경고 페이지 raw 보존 필요 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 본 sub-sub 는 `documented-only` — 여기서 "구현"은 각 방어 메커니즘의 **구성 레시피**(다음 구현자가 되묻지 않고 config 를 작성할 수준)를 뜻한다. 4가지 메커니즘 각각을 결정(D2~D5) + 근거 Claim ID 로 trace. 근거가 *원칙*만 주고 *detail*(정확한 라벨/알고리즘/저장소)을 주지 않는 cell 은 `UNSUPPORTED_IMPL_DECISION` + trade-off 한 줄(CLAUDE.md §15.5 R2).
|
||||
>
|
||||
> **OUT_OF_BRANCH_SCOPE 정제(R3)**: mTLS 실 cert 파이프라인(D2)은 프로젝트 SSOT §5 에서 project-level out-of-scope 이므로 "왜 defer 인가"의 조건만 남기고 실제 명세는 남기지 않는다. IAM 최소권한·detection(Flow Logs/GuardDuty)도 별도 관심사로 §엣지·실패·의존 으로 이관.
|
||||
|
||||
### 0. 4가지 방어책 종합 비교 (선택 요약)
|
||||
|
||||
> In-scope #1(비교·근거 문서화)의 종결 표. 개별 선택 조건이 §1~§4 에 산재하므로 여기서 한눈에 통합.
|
||||
|
||||
| 방어책 | 계층 | 운영 비용 | 보안 강도 | 도입 시점/조건 | 근거 |
|
||||
|---|---|---|---|---|---|
|
||||
| **D3** K8s NetworkPolicy | L3/L4 네트워크 (1차) | 낮음 (선언 YAML, CNI 의존) | 높음 — 우회 경로 원천 차단, 단 CNI 미구현 시 silent no-op | K8s 환경일 때 | KNP-C1/C2/C3 |
|
||||
| **D4** EC2 SG + loopback | ENI 경계 + host 경계 (1차) | 낮음 (SG rule + compose 1줄) | 높음 — 단 leftover CIDR rule union 위험 | EC2/VM 환경일 때 (단일 EC2 = loopback 우선) | AWS-SG-REF-C1/C2, DOCKER-PORT-PUB-C4 |
|
||||
| **D5** shared-secret 헤더 | app 계층 (2차) | 중간 (secret 저장+회전) | 약함 — leak 시 전면 우회 | 상시 2차 defense-in-depth (단독 1차 금지) | CF-ALB-SECRET-C3/C4/C5 |
|
||||
| **D2** mTLS | 전송 계층 (암호학적) | 높음 (cert lifecycle 자동화 인프라) | 가장 넓음 — 경계 *내부* 위협도 방어 | mesh 운영 중 / 멀티테넌트 / 규제 시만 (그 외 defer) | ISTIO-MTLS-C1/C2/C4 |
|
||||
|
||||
**핵심 선택 규칙**: ① 환경으로 1차 방어 결정 (K8s→D3, EC2→D4) → ② D5 를 상시 2차로 병행 → ③ D2 는 조건(mesh/멀티테넌트/규제) 충족 시에만 (그 전엔 network 격리로 충분).
|
||||
|
||||
### 1. K8s NetworkPolicy 2단계 구성 (D3)
|
||||
|
||||
> **Trace**: D3 / `k8s-network-policy-official#KNP-C1`(pod 기본 non-isolated → selecting NetworkPolicy 가 있어야 isolated), `#KNP-C2`(additive/union — default-deny 와 allow 를 별도 리소스로 나눠도 안전하게 합쳐짐), `#KNP-C3`(CNI 미구현 시 no effect).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (a) DNS egress 허용 레시피(port 53 → kube-dns)는 kubernetes.io 공식 문서에 verbatim 부재(community recipe 만 존재, 조사에서 확인) — trade-off: DNS egress 를 빼면 pod 이름 해석이 깨져 정상 트래픽도 실패하므로 실용상 필요하나 공식 근거 미확보, 실 구성 시 사용 CNI vendor 문서로 확인. (b) `namespaceSelector` 라벨 값은 실 클러스터의 namespace 라벨링 규약에 의존 — 임의 결정.
|
||||
|
||||
| 단계 | manifest 골자 | 근거 |
|
||||
|---|---|---|
|
||||
| 1. default-deny ingress | `podSelector: {}` + `policyTypes: [Ingress]` (ingress 규칙 없음) → backend namespace 의 모든 pod 를 isolated 로 전환 | KNP-C1 |
|
||||
| 2. ingress-namespace allow | `podSelector: <backend>` + `ingress: [{from: [{namespaceSelector: <ingress ns 라벨>}]}]` → proxy namespace 만 허용 | KNP-C2 (1·2 를 별도 리소스로 나눠도 union) |
|
||||
| 3. enforcement 확인 | 실사용 CNI 가 NetworkPolicy 를 구현하는지 확인 — `kubectl get networkpolicy` 로는 불충분(§Claims To Verify 실측) | KNP-C3 (does-not-prove: 어떤 CNI 가 구현하는지) |
|
||||
|
||||
주의(조사 확인): `from` 배열의 한 원소 안에 `namespaceSelector`+`podSelector` 를 같이 넣으면 AND(교집합), 별도 원소면 OR — "ingress namespace 의 아무 pod 든 허용"은 `namespaceSelector` **단독 원소**여야 함.
|
||||
|
||||
### 2. EC2 Security Group + loopback bind — 서로 다른 2계층 (D4)
|
||||
|
||||
> **Trace**: D4 / `aws-security-group-referencing-official#AWS-SG-REF-C1`(SG-source rule = 그 SG 소속 인스턴스만, private IP), `#AWS-SG-REF-C2`(same-VPC/peering/TGW 범위), `aws-alb-target-security-group-restriction-official#ALB-SG-C1`(target SG source = LB SG 권고), `docker-port-publishing-loopback-bind-official#DOCKER-PORT-PUB-C4`(publish flag 에 `127.0.0.1` 포함 시 Docker host 만 접근).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 이 프로젝트의 실제 토폴로지는 **ALB 없는 단일 EC2**(SSOT §F5) — "backend SG 를 ALB SG 만 허용"은 멀티 인스턴스 확장 시의 원칙 차용이고, 현 배포의 실적용 메커니즘은 **loopback bind / no-publish** 다. SG-ref 와 loopback 은 대체가 아니라 서로 다른 계층(SG=ENI 경계, loopback=host 경계)이라 병행. trade-off: 단일 EC2 에서 SG-ref 는 시연할 별도 proxy 인스턴스가 없어 문서 근거로만 남김.
|
||||
|
||||
| 계층 | 실적용(단일 EC2) | 멀티 인스턴스 확장 시 | 근거 |
|
||||
|---|---|---|---|
|
||||
| host 경계 | docker-compose 에서 backend 포트를 `127.0.0.1:8080:8080` bind 또는 `ports:` 생략(`expose:` 만) | 동일 유지 | DOCKER-PORT-PUB-C4, C3(insecure by default) |
|
||||
| ENI 경계 | (해당 없음 — proxy·backend 동일 host) | backend SG inbound source = proxy/ALB SG-reference | AWS-SG-REF-C1, ALB-SG-C1 |
|
||||
|
||||
### 3. shared-secret 헤더 검증 — defense-in-depth 2차 (D5)
|
||||
|
||||
> **Trace**: D5 / `aws-cloudfront-origin-shared-secret-header-official#CF-ALB-SECRET-C2`(헤더를 secure credential 로 취급), `#CF-ALB-SECRET-C3`(secret 유출 = 전면 우회), `#CF-ALB-SECRET-C4`(network-layer 병행 권고), `#CF-ALB-SECRET-C5`(make-before-break 회전).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (a) 근거는 AWS CloudFront→ALB 의 **구조적 동형** 패턴이며 oauth2-proxy/nginx→backend 스택의 vendor 직접 근거가 아님(유추 적용 — 과장 금지). (b) HMAC 알고리즘·secret 저장소(env var vs secret manager)·Spring backend 미들웨어 검증 코드·app-code 무중단 회전 구현은 CloudFront 문서(infra-rule 계층만)의 범위 밖 — 임의 결정. trade-off: 학습 단계는 env var + 단일 시크릿으로 충분하나, 유출 시 무력화되므로 network 격리(D3/D4) 없이 단독 1차 사용 금지.
|
||||
|
||||
| 항목 | 명세 | 근거 |
|
||||
|---|---|---|
|
||||
| 헤더 | proxy 가 `X-Internal-Auth-Token` 주입, backend 미들웨어가 검증 | CF-ALB-SECRET-C2 (구조적 동형) |
|
||||
| 저장 | secure credential 로 취급 — 평문 config 반입 금지 | CF-ALB-SECRET-C2 |
|
||||
| 회전 | make-before-break: 신규 헤더 추가 → backend 양쪽 허용 → 구 헤더 송신 중단 → 구 헤더 허용 제거 | CF-ALB-SECRET-C5 |
|
||||
| 위치 | 반드시 network 격리(D3/D4) 하위의 2차 — 단독 1차 금지 | CF-ALB-SECRET-C3, C4 |
|
||||
|
||||
### 4. mTLS — deferral 조건만 (D2, 실 cert 파이프라인은 OUT_OF_BRANCH_SCOPE)
|
||||
|
||||
> **Trace**: D2 / `istio-mtls-cert-rotation-official#ISTIO-MTLS-C1`(cert 생성·배포·rotation 자동화 필요 = 운영 비용의 실체), `#ISTIO-MTLS-C2`(주기적 자동 회전). 프로젝트 SSOT §5(mTLS project-level out-of-scope) + §F5(single-EC2).
|
||||
>
|
||||
> - **deferral 조건(언제 재검토)**: 이미 service mesh 운영 중 / 멀티테넌트 클러스터(backend namespace 를 타 팀 공유) / 규제(FAPI·PCI) 요구 → mTLS 채택. 그 외(단일 테넌트·단일 EC2·학습)는 network 격리로 충분.
|
||||
> - **실 cert 파이프라인 명세는 본 § 에 남기지 않음**(OUT_OF_BRANCH_SCOPE — 운영 전환 시 별도 branch). "mesh 부재 시 defer 결론 자체"는 여전히 UNSUPPORTED_DECISION(Decision Evidence Map D2 참조).
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4(깊이 게이트) 캡처용. 정상 경로 외에 *구현 중 부딪힐* 실패/엣지/다른 계약 의존을 미리 열거.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **NetworkPolicy silent no-op** (D3): 실사용 CNI 가 NetworkPolicy 를 구현하지 않으면(flannel default) manifest 는 API 에 저장되나 아무것도 차단하지 않음 — `kubectl get networkpolicy` 로는 탐지 불가(KNP-C3). 기대 동작: 적용 후 직접 backend pod IP 로 curl 해 차단 여부 실측(§Claims To Verify).
|
||||
- **SG rule aggregation(union)** (D4): backend 인스턴스에 연결된 **다른** SG 에 broad CIDR allow rule 이 남아있으면 SG-ref 의 narrow rule 과 union 되어 무력화(AWS-SG-REF-C3 does-not-prove — 논리적 추론). 기대 동작: 신규 SG-ref rule 추가 전 기존 rule 전수 감사.
|
||||
- **loopback bind 회귀** (D4): docker-compose 의 `127.0.0.1:8080:8080` 을 실수로 `8080:8080` 으로 되돌리면 즉시 전체 외부 노출(DOCKER-PORT-PUB-C3 "insecure by default"). SG 계층이 최후 방어선.
|
||||
- **shared-secret leak = 전면 우회** (D5): 헤더/시크릿이 유출되면 방어가 완전 무력화(CF-ALB-SECRET-C3). network 격리(1차) 없이 단독 사용 금지가 그래서 강제.
|
||||
- **mTLS deferral 누락** (D2): 프로젝트가 멀티테넌트/prod 로 전환될 때 deferral 재검토가 누락되면 network 격리 단일 계층에만 의존하게 됨.
|
||||
- **다른 계약 의존** (대상 브랜치 + Decision ID 병기):
|
||||
- 부모 [[raw/branch-notes/feature-keycloak-edge-forwardauth-no-google]] `D2`(헤더 명명 = nginx 계열 `X-Auth-Request-User` 우선) — 본 branch 의 4 방어가 지키는 "신뢰 헤더"의 이름 owner. 부모 D2 가 헤더 이름을 바꾸면 본 branch 방어 대상 입력이 바뀜.
|
||||
- 부모 [[raw/branch-notes/feature-keycloak-edge-forwardauth-no-google]] `D4`(header spoofing 방어 = ingress-only 강제, NetworkPolicy/VPC SG) — **역방향 계약**: 부모 D4 는 현재 UNSUPPORTED_DECISION 으로 enforcement detail 을 본 sub-sub 에 명시 위임("별도 raw 보존 필요", 부모 `:184`). 본 branch 의 D3/D4 + 이번 세션 보존한 `k8s-network-policy-official`·`aws-security-group-referencing-official` raw 가 그 위임을 이행 — 부모 D4 는 향후 이 근거로 갱신 가능(갱신은 부모 owner 결정, 본 branch 는 근거만 제공).
|
||||
- 형제 [[raw/branch-notes/feature-keycloak-oauth2-proxy-oidc-flow]] / [[raw/branch-notes/feature-keycloak-nginx-auth-request-integration]] — 실제로 헤더를 주입하는 proxy 구성. 이들이 정한 헤더 이름(`X-Auth-Request-User/Email/Groups`)이 본 branch 방어의 입력.
|
||||
- **D5 신규 헤더 주입 선행 의존**: 본 branch D5 가 도입하는 `X-Internal-Auth-Token` 은 형제 [[raw/branch-notes/feature-keycloak-nginx-auth-request-integration]] 의 현재 전달 헤더 목록(`X-Auth-Request-*` only, 형제 `:70`)에 **부재**. D5 실 구현 시 형제 proxy 구성이 이 헤더를 주입하도록 *확장이 선행*돼야 함(형제 branch 의 향후 Decision 이 owner — 현재는 미존재 계약).
|
||||
- 본 branch 의 D6(`KC_PROXY_TRUSTED_ADDRESSES`) — Keycloak reverse-proxy 헤더 계약([[raw/branch-notes/feature-keycloak-reverse-proxy-headers]] 계열)에 의존. proxy 신뢰 주소 정책이 바뀌면 D6 도 영향.
|
||||
- **범위 밖(별도 관심사, 본 branch 미소유)**: IAM 최소권한(SG 수정 권한 scoping) = SG 방어(D4)를 우회할 수 있는 control-plane 위협이나 네트워크 결정과 독립 / Detection 계층(VPC Flow Logs·GuardDuty) = prevention 아님. 둘 다 본 branch 결정 범위 밖으로 명시.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 4가지 방어책의 실효성과 운영 비용은 vendor doc 만으로 검증 불가. 다음은 실 환경 시연 시 실측 필요.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| K8s `NetworkPolicy` 가 CNI 미지원 (flannel default) 환경에서 silent failure 하는지 — `kubectl get networkpolicy` 만으로 enforcement 여부 확인 불가 | `D3` 는 이제 `raw/official-docs/k8s-network-policy-official.md#KNP-C3` 로 "CNI 미구현 시 no effect" 원칙 자체는 공식 근거 확보(2026-07-16). 단 이 학습 프로젝트가 실제 사용하는 CNI 가 NetworkPolicy 를 구현하는지, `kubectl get networkpolicy` 만으로 enforcement 여부를 판별 가능한지는 KNP-C3 의 인용 범위 밖(does-not-prove) — 여전히 실측 필요 | flannel(또는 실사용 CNI) 환경에서 NetworkPolicy 적용 후 ingress 우회 시도 (직접 backend pod IP curl) 로 enforce 여부 확인 | `needs-confirmation` |
|
||||
| EC2 Security Group inbound 가 ALB/proxy SG-reference 만 허용해도 VPC 내부 다른 인스턴스의 lateral movement 차단 가능한지 | `D4` 는 이제 `aws-security-group-referencing-official#AWS-SG-REF-C1/C2` 로 "SG-source = SG 소속 인스턴스만·same-VPC 범위" 동작은 공식 근거 확보(2026-07-16). 단 실 배포 backend 인스턴스에 leftover broad CIDR allow rule 이 없는지(union 무력화 여부, AWS-SG-REF-C3 does-not-prove)는 실측 필요 | bastion 인스턴스에서 backend `:8080` 직접 curl 시도 후 차단 여부 확인 + 기존 SG rule 전수 감사 | `planned` |
|
||||
| shared-secret 헤더 (`X-Internal-Auth-Token: hmac`) 가 backend middleware 에서 실제로 우회 차단을 하는지 | `D5` 는 이제 `raw/official-docs/aws-cloudfront-origin-shared-secret-header-official.md#CF-ALB-SECRET-C1~C5` 로 CloudFront→ALB 맥락의 패턴·실패 모드·회전 절차는 공식 근거 확보(2026-07-16). 단 본 프로젝트의 oauth2-proxy/nginx→backend 조합에서 동일 mitigation 이 실제로 동작하는지, 헤더 leak 시 무력화 위험도가 정량적으로 어느 정도인지는 CloudFront 인용 범위 밖(구조적 유사성만 인용 — does-not-prove) — 여전히 실측 필요 | shared-secret 없는 직접 요청과 위조 요청을 backend 에 보내 응답 차이 확인 | `planned` |
|
||||
| Keycloak `KC_PROXY_TRUSTED_ADDRESSES=127.0.0.1` 가 단일 EC2 환경에서 spoofing 차단에 충분한지 | `KC-RP-C5` does-not-prove "화이트리스트 외 IP 의 정확한 동작" | 외부에서 직접 Keycloak `:8080` 에 `X-Forwarded-For: 8.8.8.8` 위조 요청 후 로그에 위조 IP 가 기록되는지 확인 | `needs-confirmation` |
|
||||
| nginx + oauth2-proxy 의 `X-Auth-Request-User` 헤더가 backend 도달 전에 ingress 외부에서 주입된 동일 이름 헤더로 위조 가능한지 | `O2PN-C3` does-not-prove "backend 가 X-User 헤더를 신뢰해도 안전" | 외부 client 가 `X-Auth-Request-User: admin` 헤더를 포함한 요청을 ingress 와 backend 양쪽에 보내 처리 결과 비교 | `needs-confirmation` |
|
||||
| Traefik `trustForwardHeader=true` deprecated 의 대체 옵션 (정확한 신규 이름과 동작) | `TFA-C6` does-not-prove "대체 옵션명" | Traefik 공식 deprecated 경고 페이지 추가 raw 보존 후 신규 옵션 시연 | `planned` |
|
||||
| K8s NetworkPolicy 공식 vendor doc raw 보존 ([[raw/official-docs/k8s-network-policy-official]]) | (해결됨 — 2026-07-16 raw 보존 완료, KNP-C1/C2/C3 추출로 D3 UNSUPPORTED_DECISION 해소) | kubernetes.io 공식 NetworkPolicy 페이지를 `raw-source-template` 으로 보존 후 Claim ID 추출 | `done` |
|
||||
| mTLS ingress↔backend 의 cert 발급/회전 운영 비용이 위협 모델 대비 정당한지 | `D2` UNSUPPORTED — vendor 인용 부재 + 학습 단계 deferral | cert-manager 또는 SPIFFE/SPIRE 등 cert 자동화 도구 비교 + 회전 주기별 운영 비용 측정 | `planned` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 아직 없음(문서 단계).
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/aws-alb-target-security-group-restriction-official]]
|
||||
- [[raw/official-docs/aws-cloudfront-origin-shared-secret-header-official]]
|
||||
- [[raw/official-docs/aws-security-group-referencing-official]]
|
||||
- [[raw/official-docs/docker-port-publishing-loopback-bind-official]]
|
||||
- [[raw/official-docs/istio-mtls-cert-rotation-official]]
|
||||
- [[raw/official-docs/k8s-network-policy-official]]
|
||||
- [[raw/official-docs/keycloak-reverseproxy-official]]
|
||||
- [[raw/official-docs/nginx-auth-request-module-official]]
|
||||
- [[raw/official-docs/traefik-forwardauth-middleware-official]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
- [[raw/branch-notes/feature-keycloak-traefik-forwardauth-alternative]]
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
> 본 sub-sub-branch 는 leaf — 자식 branch 없음. Phase 3 P3A 실 구현 또는 외부 산출물 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화. (근거 raw 자료 8건은 §Sources / 근거 에 단일 관리 — 여기 중복 나열하지 않음.)
|
||||
|
||||
### 오류 기록 (이 sub-sub-branch 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 documented-only 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — Phase 3 실 구현 단계에 누적)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 본 sub-sub-branch는 **문서까지만**. wiki 추출은 root branch의 비교 매트릭스 시점에 일괄 처리.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: 해당 없음 (문서 단계)
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목: 없음
|
||||
- `locally-verified` 항목: 없음
|
||||
- `prod-verified` 항목: 없음
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
- 본 sub-sub-branch 전체가 `documented-only` 등급. P1A 부모 sub-branch와 함께 추후 비교 매트릭스 / `wiki/concepts/keycloak-deployment-patterns.md`에 인용되는 형태로만 활용. `wiki/projects/` 직접 승급 없음.
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
---
|
||||
title: branch / feature-keycloak-https-termination-caddy-nginx (P3B HTTPS termination — Caddy vs nginx + Let's Encrypt)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-CHILD-A34AB4E8
|
||||
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-https-termination-caddy-nginx
|
||||
parent_branch: feature-keycloak-patterns
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, p3b, https, tls, caddy, nginx, letsencrypt, acme]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 3cb0c15cd9eed9060bbf609a1eceb775ab560d7bdafc292900454af3c43aac1c
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-https-termination-caddy-nginx (P3B HTTPS termination — Caddy vs nginx + Let's Encrypt)
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/branch-notes/feature-keycloak-patterns]]의 WI020 child branch. **HTTPS termination 위치와 cert 자동화 옵션 비교**. Cloudflare Tunnel은 edge TLS 자동, EC2 public IP는 Caddy 또는 nginx + certbot 필요.
|
||||
> 본 sub-sub-branch는 **문서까지만** — 실 Caddy / nginx 설치 / cert 발급 / cron 등록은 진행하지 않음. 등급 `documented-only`.
|
||||
> `status_label`: `in-progress`
|
||||
|
||||
<!-- 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 변형이다 | HTTPS termination을 인증 패턴 공통의 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 -->
|
||||
## 목표
|
||||
|
||||
Google OAuth가 redirect_uri로 HTTPS를 강제(`-6-3` 참조)하므로 P3B는 어떤 식으로든 HTTPS가 필수. 단일 EC2 학습 환경에서 다음 4개 옵션의 trade-off를 정리:
|
||||
1. **Caddy** — 1줄 config로 Let's Encrypt 자동 (ACME-TLS-ALPN-01 / HTTP-01)
|
||||
2. **nginx + certbot** — 직접 cert 발급 + cron으로 renew
|
||||
3. **Cloudflare Tunnel** — origin은 HTTP, edge가 TLS 종단 (edge-managed certificate)
|
||||
4. **EC2 + ALB + ACM** — production-like, AWS managed cert
|
||||
|
||||
이 결정이 운영 비용(cert renew burden, 만료 사고) + 학습 friction(설치 복잡도)를 좌우.
|
||||
|
||||
면접에서 답해야 할 질문:
|
||||
1. Caddy를 학습용으로 왜 우선 골랐나? → 짧은 config와 자동 ACME로 수동 발급·갱신 부담을 줄이기 때문이다.
|
||||
2. nginx + certbot vs Caddy의 차이는? → nginx는 명시적 (server block + ssl_certificate path), certbot이 별도 binary로 cert 발급/갱신. Caddy는 통합.
|
||||
3. HSTS / TLS 1.2+ enforce 어디서? → reverse proxy 레이어. Caddy는 Automatic HTTPS로 HTTP→HTTPS redirect를 제공하지만 **HSTS는 `header` directive로 명시**해야 한다. nginx도 명시 설정한다.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- Caddy `auto_https on` (디폴트) + 1줄 config로 Let's Encrypt 발급
|
||||
- nginx + certbot (또는 acme.sh) CLI 흐름, cron / systemd timer 등록
|
||||
- Cloudflare Tunnel edge TLS (origin HTTP, edge HTTPS) 흐름
|
||||
- EC2 + ALB + ACM (production-like 대안) — 비교만
|
||||
- cert 갱신 자동화 (certbot.timer / cron / Caddy 내장 / Cloudflare 자동)
|
||||
- TLS 1.2+ enforce 설정 위치
|
||||
- HSTS 헤더 (`Strict-Transport-Security`) 설정 위치
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 실 cert 발급 (도메인 소유 검증 필요 → P3A 완료 후 선택적 확장)
|
||||
- mTLS / client cert auth
|
||||
- TLS 1.3 0-RTT
|
||||
- HPKP (deprecated)
|
||||
- HAProxy / Traefik 옵션
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
- (외부 근거는 부모 P3B의 외부 자료 inventory 공유 — 본 sub-sub-branch는 학습 환경 옵션 비교 + 결정 근거에 집중)
|
||||
- [[raw/official-docs/cloudflare-tunnel-routing-official]] — Cloudflare Tunnel edge TLS 동작 (sub-sub-branch `-6-1`과 공유) — **D1** 근거
|
||||
- [[raw/official-docs/caddy-automatic-https-docs]] — Caddy Automatic HTTPS 공식 (`official-vendor-doc`) — **D2** 근거 (auto-HTTPS + Let's Encrypt/ZeroSSL + HTTP→HTTPS redirect + background renewal). HSTS default 는 본 raw 로 입증 안 됨 (`CADDY-AHTTPS-C11`)
|
||||
- [[raw/official-docs/certbot-user-guide]] — EFF Certbot User Guide (`official-vendor-doc`) — **D3** 근거 (subcommand + automated renewal scheduled task + `--nginx` plugin + `--deploy-hook`)
|
||||
- [[raw/official-docs/aws-acm-managed-renewal]] — AWS ACM Managed Renewal 공식 (`official-vendor-doc`) — **D4** 근거 (DNS-validated cert fully automated renewal + ARN 유지 + ELB/CloudFront attach 자격 + EventBridge alert)
|
||||
- [[raw/official-docs/rfc8996-tls10-tls11-deprecation]] — RFC 8996 (`official-standard`, IETF Standards Track) — **D5** TLS 1.0/1.1 MUST NOT 근거
|
||||
- [[raw/official-docs/owasp-hsts-cheat-sheet]] — OWASP HSTS Cheat Sheet (`official-reference`, 표준 아님) — **D5** HSTS 권장 헤더 + preload 경고 근거
|
||||
|
||||
## 관련 sub-branch
|
||||
|
||||
- [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]] (부모, P3B Single EC2 + Google federation)
|
||||
- [[raw/branch-notes/feature-keycloak-public-domain-tunneling]] — 학습 환경 public 도메인 확보 **← Cloudflare Tunnel 결정과 결합**
|
||||
- [[raw/branch-notes/feature-keycloak-reverse-proxy-headers]] — Keycloak reverse proxy 설정 **← Caddy / nginx config의 reverse proxy 측면**
|
||||
- [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]] — Google OAuth client 등록 (HTTPS redirect_uri 강제 근거)
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기.
|
||||
|
||||
- [ ] **Caddy 설치 + 1줄 config 정리** — `apt install caddy` 또는 docker image, `Caddyfile`에 `kc.example.com { reverse_proxy localhost:8080 }` 1줄 → `auto_https on` 디폴트로 Let's Encrypt 자동 발급 — 등급: `planned`
|
||||
- [ ] **nginx + certbot 흐름 정리** — `apt install nginx certbot python3-certbot-nginx` → `certbot --nginx -d kc.example.com` → server block에 `ssl_certificate` 자동 삽입 → `certbot renew --dry-run` 검증 → `systemctl enable certbot.timer` — 등급: `planned`
|
||||
- [ ] **acme.sh 대안 비교** — certbot 대비 shell-only / 더 가벼움 / 다양한 DNS provider 지원 — 등급: `documented-only`
|
||||
- [ ] **Cloudflare Tunnel edge TLS 흐름** — origin은 `http://localhost:8080`, edge certificate는 공급자가 관리 → 학습용 1순위 — 등급: `planned`
|
||||
- [ ] **EC2 + ALB + ACM (production-like)** — Route53 hosted zone + ALB + ACM cert + Target Group → EC2:80. ACM 자동 갱신. 학습 비용 ↑ — 등급: `documented-only`
|
||||
- [ ] **cert 갱신 자동화 비교표** — certbot.timer / cron `0 3 * * * certbot renew --quiet` / Caddy background 갱신(정확 시점은 issuer 정책 의존) / Cloudflare edge 관리 / ACM managed renewal — 등급: `planned`
|
||||
- [ ] **TLS 1.2+ enforce 정책** — Caddy 디폴트로 TLS 1.2 minimum, nginx는 `ssl_protocols TLSv1.2 TLSv1.3;` 명시 — 등급: `planned`
|
||||
- [ ] **HSTS 헤더 정책** — `Strict-Transport-Security: max-age=31536000; includeSubDomains`. Caddy는 `header` directive, nginx는 `add_header`, Cloudflare는 dashboard에서 각각 **명시** — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- Caddy Automatic HTTPS 동작: HTTP(80) 요청을 HTTPS로 redirect하고 ACME 인증서 발급·background 갱신을 수행한다. HSTS는 이 기능의 default가 아니며 별도 `header` 설정이 필요하다. 정확한 갱신 시점 수치는 issuer 정책에 의존한다.
|
||||
- certbot --nginx plugin은 nginx config 자동 수정. 수동 관리하려면 `--webroot` 또는 `--standalone`.
|
||||
- Let's Encrypt rate limit: 도메인당 주 50회 cert 발급. 학습 중 반복 실수 주의.
|
||||
- Cloudflare Tunnel origin이 HTTP라도 edge ↔ Cloudflare ↔ origin 구간은 Cloudflare 사설 네트워크. 클라이언트 ↔ edge는 HTTPS. origin port 0 open 가능 (egress only).
|
||||
- ALB + ACM은 cert 자동 갱신 + AWS-native, 비용은 ALB $20~/월 + 트래픽. 학습 환경에 과함.
|
||||
|
||||
### 옵션 비교표 초안
|
||||
|
||||
| 항목 | Caddy | nginx + certbot | Cloudflare Tunnel | EC2 + ALB + ACM |
|
||||
|------|-------|-----------------|-------------------|------------------|
|
||||
| 설정 복잡도 | 1줄 | server block + certbot CLI | tunnel config (yml) | Terraform / 콘솔 |
|
||||
| cert 발급 | 자동 (Let's Encrypt) | 수동 1회 (certbot) | 자동 (Cloudflare) | 자동 (ACM) |
|
||||
| cert 갱신 | 자동 (내장) | `certbot.timer` (자동) | 자동 (Cloudflare) | 자동 (ACM) |
|
||||
| 만료 사고 위험 | 낮음 | 중 (cron 실패 시) | 낮음(공급자 관리) | 낮음(조건 충족 시 managed renewal) |
|
||||
| TLS 1.2+ enforce | 디폴트 | 명시 (`ssl_protocols`) | dashboard | ALB Security Policy |
|
||||
| HSTS | `header` 명시 (default 아님 — `CADDY-AHTTPS-C11`) | `add_header` 명시 | dashboard | ALB / WAF |
|
||||
| 비용 | 무료 | 무료 | 무료 (Cloudflare account) | ALB ~$20/월 + 트래픽 |
|
||||
| 학습 환경 적합도 | 높음 | 중 | 매우 높음 (edge-managed certificate) | 낮음 (과함) |
|
||||
| 운영 환경 적합도 | 중~높음 | 높음 (전통) | 중 (vendor 의존) | 매우 높음 (AWS-native) |
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- **2026-05-25**: 학습 환경 1순위 **Cloudflare Tunnel** (edge TLS). 이유: certificate lifecycle을 edge 공급자가 관리해 학습자의 수동 발급·갱신 부담이 작다. 갱신 실패가 없다는 보장은 하지 않는다. sub-sub-branch `-6-1` Cloudflare Tunnel 결정과 자연스럽게 연결.
|
||||
- **2026-05-25**: 학습 환경 2순위 **Caddy** (EC2 public IP를 직접 노출하는 시나리오). 이유: 1줄 config + auto_https + Let's Encrypt 내장. (주의: **HSTS 는 Caddy default 아님** — `header` directive 명시 필요. `CADDY-AHTTPS-C11` 이 공식 페이지에 HSTS 언급 부재를 근거로 기록. 초기 메모의 "HSTS 디폴트"는 반증됨.)
|
||||
- **2026-05-25**: nginx + certbot은 비교 대상으로만. 이유: 전통적 운영 환경에서는 표준이나 학습 friction이 Caddy 대비 높음 (cron renew 실패 사고).
|
||||
- **2026-05-25**: EC2 + ALB + ACM은 운영 환경 대안으로만 기재. 본 P3B 범위는 단일 EC2 학습 → ALB / multi-AZ는 과함.
|
||||
- **2026-05-25**: TLS 1.2+ enforce + HSTS는 어떤 옵션을 선택하든 강제. 학습이라도 보안 디폴트 leak 안 함.
|
||||
- **2026-05-25**: 본 sub-sub-branch 전체 등급 `documented-only`. 실 cert 발급 / Caddy 또는 nginx 구동은 P3A 완료 후 선택적 확장 시점에 재검토.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. `Decision ID` 는 본 branch-note 안에서 안정적으로 유지.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | 학습 환경 1순위 **Cloudflare Tunnel** (edge TLS) — certificate lifecycle을 공급자가 관리 | 수동 cert 발급·갱신 부담을 줄이고 public 도메인을 Cloudflare 로 확보할 때 이 결정. 갱신 실패가 없다는 보장은 하지 않는다. EC2 public IP 를 직접 노출해야 하면 → D2(Caddy) | `raw/official-docs/cloudflare-tunnel-routing-official.md#CLOUDFLARE-TUNNEL-C1` (cloudflared outbound-only), `raw/official-docs/cloudflare-tunnel-routing-official.md#CLOUDFLARE-TUNNEL-C2` (firewall inbound 차단 권장), `raw/official-docs/cloudflare-tunnel-routing-official.md#CLOUDFLARE-TUNNEL-C5` (locally-managed tunnel DNS routing 명령) | `official-vendor-doc` | TLS 가 Cloudflare edge ↔ origin 구간에서 어떻게 종단되는지 (origin HTTP) 의 정확한 header 동작과 certificate 갱신 조건은 본 raw 인용으로 직접 보증 안 됨 — 로컬 검증 및 별도 SSL/TLS 공식 문서 필요 (`feature-keycloak-reverse-proxy-headers` 의 X-Forwarded-* 와 결합) |
|
||||
| D2 | 학습 환경 2순위 **Caddy** (1줄 config + auto_https + Let's Encrypt 내장; HSTS 는 `header` directive 명시 필요 — default 아님, `CADDY-AHTTPS-C11`) | EC2 public IP 를 직접 노출(Cloudflare 미사용)하고 1줄 config 로 cert 자동화를 원할 때. 전통 nginx 스택 표준화가 목표면 → D3 | `raw/official-docs/caddy-automatic-https-docs.md#CADDY-AHTTPS-C1` (TLS cert 자동 발급 + 자동 갱신 — "Automatic HTTPS provisions TLS certificates for all your sites and keeps them renewed"), `#CADDY-AHTTPS-C2` (default 로 모든 site 를 HTTPS 로 serve), `#CADDY-AHTTPS-C3` (public ACME CA — Let's Encrypt 또는 ZeroSSL), `#CADDY-AHTTPS-C4` (HTTP → HTTPS redirect + managed cert 자동 갱신 default), `#CADDY-AHTTPS-C5` (renewal background 수행, subdomain 은 명시 설정 필요), `#CADDY-AHTTPS-C6` (hostname 인식 시 implicit 활성화) | `official-vendor-doc` | **"HSTS 디폴트" 주장은 본 raw 로 입증 불가** — `CADDY-AHTTPS-C11` 은 본 페이지에 HSTS 직접 언급 없음을 명시적 부재 사실로 기록. D2 의 HSTS 부분은 별도 출처 (Caddy `tls` / `header` directive 페이지) 보강 필요. 또한 `CADDY-AHTTPS-C5` 는 renewal background 만 보증, "만료 30일 전" 같은 정확 timing 은 별도 ACME issuer 정책 의존 |
|
||||
| D3 | nginx + certbot 은 비교 대상으로만 (학습 friction, cron renew 실패 사고 위험) | 기존 nginx 운영 표준에 편입해야 할 때만. 학습·저마찰이 목표면 → D2(Caddy). 본 branch 에서는 비교 baseline 으로만 | `raw/official-docs/certbot-user-guide.md#CERTBOT-UG-C1` (subcommand 체계 — obtain/renew/revoke), `#CERTBOT-UG-C2` (대부분 installation 은 automated renewal preconfigured — `certbot renew` scheduled task), `#CERTBOT-UG-C3` (scheduled task 구체 구현은 OS/installer 의존), `#CERTBOT-UG-C4` (Nginx plugin — "should work for most configurations", `--nginx rollback` 지원), `#CERTBOT-UG-C5` (Certbot 4.0.0 부터 renewal 임계 = lifetime 의 1/3 미만), `#CERTBOT-UG-C6` (`--pre-hook` / `--post-hook` / `--deploy-hook` 지원) | `official-vendor-doc` | "cron renew 실패 사고 위험" 의 정량적 비교는 본 raw 로 직접 보증되지 않음 — `CERTBOT-UG-C3` 가 "OS / installer 의존" 만 진술. 실제 systemd timer vs cron 의 신뢰성 비교는 운영 사례 / wiki 합성 단계에서 별도 분석 필요 |
|
||||
| D4 | EC2 + ALB + ACM 은 운영 환경 대안으로만 기재 (단일 EC2 학습 범위 초과) | multi-AZ / production-grade managed cert 가 요구될 때. 단일 EC2 학습 범위면 → D1/D2. 본 branch 에서는 운영 대안 기재만 | `raw/official-docs/aws-acm-managed-renewal.md#AWS-ACM-RENEW-C1` (Amazon-issued cert 의 managed renewal — DNS validation 시 자동), `#AWS-ACM-RENEW-C2` (public + private cert 모두 적용), `#AWS-ACM-RENEW-C3` (ELB / CloudFront 등 AWS service attach 시 자동 갱신 ELIGIBLE), `#AWS-ACM-RENEW-C8` (갱신 시 ARN 유지 → listener config 무수정), `#AWS-ACM-RENEW-C9` (regional resource — multi-region 독립 갱신), `#AWS-ACM-RENEW-C11` (DNS validation cert 의 fully automated renewal), `#AWS-ACM-RENEW-C12` (만료 45일 전 갱신 시도, legacy 395-day cert 의 경우 60일 전), `#AWS-ACM-RENEW-C13` (갱신 사전 조건: AWS service 사용 중 + CNAME public DNS 존재), `#AWS-ACM-RENEW-C14` (실패 시 EventBridge alert 30/15/7/3/1일 전) | `official-vendor-doc` | **ALB Security Policy (TLS 1.2 enforce)** 는 본 raw 로 입증 불가 — ACM 은 cert 발급/갱신만 진술, listener TLS policy 는 ELB 측 별도 문서. "ACM cert renew 실패 사고 0" 도 본 raw 가 직접 보증하지 않음 (`C14` 는 alert schedule 만 진술) — Route53 CNAME 영구 유지 + AWS service attach 가 동시 충족되어야 함 (`C13`) |
|
||||
| D5 | TLS 1.2+ enforce + HSTS 는 어떤 옵션을 선택하든 강제 (학습이라도 보안 디폴트 leak 안 함) | N/A — D1~D4 어느 옵션을 선택하든 무조건 강제 (분기 없음) | TLS 1.0/1.1 deprecation: `raw/official-docs/rfc8996-tls10-tls11-deprecation.md#RFC8996-C1` (TLS 1.0/1.1 formally deprecated → Historic), `#RFC8996-C4` ("TLS 1.0 MUST NOT be used"), `#RFC8996-C5` ("TLS 1.1 MUST NOT be used"), `#RFC8996-C6` (BCP 195 의 SHOULD NOT → MUST NOT 강화). HSTS: `raw/official-docs/owasp-hsts-cheat-sheet.md#OWASP-HSTS-C1` (HSTS 는 opt-in response header), `#OWASP-HSTS-C2` (활성 시 HTTP → HTTPS 자동 redirect), `#OWASP-HSTS-C3` (invalid cert 경고 override 불가), `#OWASP-HSTS-C4` (권장 헤더 예시 `max-age=63072000; includeSubDomains; preload`), `#OWASP-HSTS-C6` (`includeSubDomains` 생략 시 cookie 공격 위험) | TLS 1.0/1.1 deprecation: `official-standard` (RFC 8996 = IETF Standards Track). HSTS: `official-reference` (OWASP cheatsheet — 표준 아님; RFC 6797 별도) | RFC 8996 은 **TLS 1.0/1.1 의 MUST NOT** 만 보증 — "TLS 1.2 가 충분히 안전" 또는 "TLS 1.3 권장" 은 별도 RFC 8446 / 8447 영역. OWASP HSTS 는 cheatsheet (reference) 이므로 official standard 로 격상 금지. `OWASP-HSTS-C5` 의 preload PERMANENT CONSEQUENCES 는 학습 도메인에 preload 금지 권고로 반영 필요 (Claims To Verify §HSTS preload 항목 참조) |
|
||||
| D6 | 본 sub-sub-branch 전체 등급 `documented-only` (실 cert 발급 / Caddy or nginx 구동 보류) | N/A — 부모 P3B 의 documented-only 정책에 종속, 실 구동은 P3A 완료 후 재검토 | UNSUPPORTED_DECISION (project scope 결정 — 외부 raw 가 아닌 부모 branch P3B 의 `documented-only` 정책에 종속) | `UNSUPPORTED_DECISION` | scope 결정 자체는 외부 raw 인용 불필요 — 부모 branch decision 만 root |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 본 branch 는 전체 등급 `documented-only` (D6) — 실 cert 발급/구동은 P3A 완료 후 선택적 확장. 따라서 본 §는 *확장 시 되묻지 않도록* 각 옵션의 config-level 명세를 결정별로 catalog 한다.
|
||||
> **주의 (R2 라벨 원칙)**: config 문법 detail (Caddyfile 정확한 syntax / nginx directive line / `cloudflared` config key / ALB Security Policy) 은 수집한 raw 가 *동작 원리*만 보증하고 *정확한 문법*은 보증하지 않으므로 `UNSUPPORTED_IMPL_DECISION` 라벨 — 실 확장 시 vendor 문법 페이지로 재확인 대상 (Claims To Verify 와 연결).
|
||||
|
||||
### 1. Cloudflare Tunnel edge TLS (학습 1순위)
|
||||
|
||||
> **Trace**: D1 → `CLOUDFLARE-TUNNEL-C1` (cloudflared outbound-only), `-C2` (inbound firewall 차단), `-C5` (locally-managed tunnel DNS routing).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `cloudflared` config 의 정확한 `ingress:` 문법 + `service: http://localhost:8080` 매핑은 수집 raw 에 없음 (routing 원리만 보증) — trade-off: 학습 1순위라 우선 문서화하나 실 확장 시 Cloudflare Tunnel config 페이지 인용 보강 필요.
|
||||
|
||||
| 항목 | 명세 | 근거 / 라벨 |
|
||||
|---|---|---|
|
||||
| Origin | `http://localhost:8080` (평문, egress-only) | D1 / `CLOUDFLARE-TUNNEL-C1` |
|
||||
| Edge TLS | Cloudflare edge 가 TLS 종단하고 edge certificate lifecycle을 관리 | D1 (정확한 갱신 보장은 Claims To Verify §Cloudflare 확인 대상) |
|
||||
| Inbound port | EC2 inbound 전부 차단 (tunnel egress-only) | D1 / `CLOUDFLARE-TUNNEL-C2` |
|
||||
| DNS routing | locally-managed tunnel → `cloudflared` DNS route 명령 | D1 / `CLOUDFLARE-TUNNEL-C5` |
|
||||
| X-Forwarded-Proto | edge 가 `https` 주입 → Keycloak 이 소비 | 의존: [[raw/branch-notes/feature-keycloak-reverse-proxy-headers]] D1 (§엣지·실패·의존) |
|
||||
|
||||
### 2. Caddy 1줄 config (학습 2순위 — EC2 직접 노출)
|
||||
|
||||
> **Trace**: D2 → `CADDY-AHTTPS-C1` (cert 자동 발급+갱신), `-C2` (default HTTPS serve), `-C3` (Let's Encrypt/ZeroSSL ACME), `-C4` (HTTP→HTTPS redirect), `-C5` (background renewal).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (a) `Caddyfile` 정확한 문법 `kc.example.com { reverse_proxy localhost:8080 }` 은 수집 raw 미보증(auto-HTTPS 원리만) — trade-off: 관행적으로 널리 쓰이나 문법은 Caddyfile 페이지로 재확인. (b) **"HSTS 디폴트" 는 `CADDY-AHTTPS-C11` 이 부재를 명시** → HSTS 는 Caddy `header` directive 로 *명시* 필요로 가정, default 로 단정 금지 (D2 Open Risk 와 동일).
|
||||
|
||||
| 항목 | 명세 | 근거 / 라벨 |
|
||||
|---|---|---|
|
||||
| Config | `Caddyfile` 1줄: `kc.example.com { reverse_proxy localhost:8080 }` | D2 / UNSUPPORTED_IMPL_DECISION (문법) |
|
||||
| Cert 발급 | `auto_https` default → public ACME CA (Let's Encrypt/ZeroSSL) | D2 / `CADDY-AHTTPS-C3` |
|
||||
| Cert 갱신 | background 자동 (정확한 "만료 N일 전" timing 은 ACME issuer 정책 의존) | D2 / `CADDY-AHTTPS-C5` |
|
||||
| HTTP→HTTPS | default redirect | D2 / `CADDY-AHTTPS-C4` |
|
||||
| HSTS | `header Strict-Transport-Security ...` **명시 필요** (default 로 단정 금지) | D2 / `CADDY-AHTTPS-C11` (부재 근거) → UNSUPPORTED_IMPL_DECISION |
|
||||
| TLS min | TLS 1.2 minimum (Caddy 관행 default) | D5 / UNSUPPORTED_IMPL_DECISION (정확 min version 은 Caddy tls 페이지 재확인) |
|
||||
|
||||
### 3. nginx + certbot (운영 비교 baseline)
|
||||
|
||||
> **Trace**: D3 → `CERTBOT-UG-C1` (subcommand), `-C2` (automated renewal preconfigured), `-C4` (`--nginx` plugin), `-C5` (4.0.0+ renewal 임계 = lifetime 1/3), `-C6` (deploy-hook).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: nginx `server` block 의 정확한 directive (`ssl_protocols TLSv1.2 TLSv1.3;`, `add_header Strict-Transport-Security ...`) 는 certbot raw 가 보증 안 함 (certbot 은 cert 발급/갱신만) — trade-off: nginx TLS/HSTS directive 는 nginx 문서 영역이고 본 branch 는 비교 baseline 이라 원리 수준만.
|
||||
|
||||
| 항목 | 명세 | 근거 / 라벨 |
|
||||
|---|---|---|
|
||||
| 발급 | `certbot --nginx -d kc.example.com` → server block 자동 삽입 | D3 / `CERTBOT-UG-C4` |
|
||||
| 갱신 | `certbot.timer` (automated renewal preconfigured), 임계 = lifetime 1/3 | D3 / `CERTBOT-UG-C2`, `-C5` |
|
||||
| deploy-hook | `--deploy-hook` 으로 갱신 후 nginx reload | D3 / `CERTBOT-UG-C6` |
|
||||
| TLS min | `ssl_protocols TLSv1.2 TLSv1.3;` **명시** | D5 / UNSUPPORTED_IMPL_DECISION (nginx directive) |
|
||||
| HSTS | `add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;` **명시** | D5 / `OWASP-HSTS-C4` (헤더 값) + UNSUPPORTED_IMPL_DECISION (nginx add_header 문법) |
|
||||
|
||||
### 4. EC2 + ALB + ACM (운영 대안 — 기재만)
|
||||
|
||||
> **Trace**: D4 → `AWS-ACM-RENEW-C1`/`-C11` (DNS-validated 자동 갱신), `-C3` (ELB attach 자격), `-C8` (ARN 유지 → 무수정 갱신), `-C12` (만료 45/60일 전 갱신), `-C13` (갱신 사전조건), `-C14` (EventBridge alert).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: ALB **Security Policy (TLS 1.2 enforce)** 는 ACM raw 밖 (ELB listener 문서 영역) — D4 Open Risk 와 동일. trade-off: 운영 대안 기재만이므로 Terraform/console 실배치는 본 branch scope 밖.
|
||||
|
||||
| 항목 | 명세 | 근거 / 라벨 |
|
||||
|---|---|---|
|
||||
| Cert | ACM DNS-validated → fully automated renewal | D4 / `AWS-ACM-RENEW-C11` |
|
||||
| Attach | ALB listener 에 attach (ARN 유지 → 무수정 갱신) | D4 / `AWS-ACM-RENEW-C3`, `-C8` |
|
||||
| 갱신 사전조건 | AWS service 사용 중 + CNAME public DNS 유지 | D4 / `AWS-ACM-RENEW-C13` |
|
||||
| 실패 alert | EventBridge 30/15/7/3/1일 전 | D4 / `AWS-ACM-RENEW-C14` |
|
||||
| TLS policy | ALB Security Policy = TLS 1.2 min | D5 / UNSUPPORTED_IMPL_DECISION (ELB 문서 영역) |
|
||||
|
||||
### 5. cert 갱신 자동화 + TLS/HSTS enforce 위치 (cross-cutting)
|
||||
|
||||
> **Trace**: D5 → `RFC8996-C4`/`-C5` (TLS 1.0/1.1 MUST NOT), `OWASP-HSTS-C4` (권장 헤더값), `-C6` (`includeSubDomains` 생략 위험). 갱신 메커니즘은 옵션별 §1~§4 참조.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 각 proxy 의 TLS min / HSTS 를 *어느 config 라인에* 넣는지의 정확한 문법은 §2~§4 라벨 참조 — 값(`max-age`, `includeSubDomains`)은 `OWASP-HSTS-C4` 로 보증, 위치·문법은 vendor 별.
|
||||
|
||||
| 옵션 | cert 갱신 | TLS 1.2+ enforce 위치 | HSTS 위치 |
|
||||
|---|---|---|---|
|
||||
| Cloudflare Tunnel | edge 자동 (D1) | Cloudflare dashboard | dashboard (edge) |
|
||||
| Caddy | 내장 background (D2) | `tls` directive (관행 default) | `header` directive **명시** (§2) |
|
||||
| nginx+certbot | `certbot.timer` (D3) | `ssl_protocols` (§3) | `add_header` (§3) |
|
||||
| ALB+ACM | ACM 자동 (D4) | ALB Security Policy (§4) | ALB / WAF |
|
||||
|
||||
> **HSTS 공통 정책** (D5): 권장값 `max-age=63072000; includeSubDomains` (`OWASP-HSTS-C4`), `includeSubDomains` 생략 시 cookie 공격 노출(`OWASP-HSTS-C6`). **`preload` 는 학습 도메인에 금지** — 되돌리기 PERMANENT (Claims To Verify §HSTS preload).
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4(깊이 게이트) 캡처. 본 branch 는 `documented-only` 이므로 아래 실패 경로는 *실 확장 시* 부딪힐 함정(진행 중 메모·마주친 문제에서 승격)이고, 의존은 *TLS 종단 위치가 다른 계약에 미치는 영향*이다.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **Let's Encrypt rate limit** — 도메인당 주 50회 발급. 디버깅 반복 시 `--staging` endpoint 사용 (D2/D3 확장 시). → Claims To Verify §rate limit (공식 raw 미수집).
|
||||
- **ACME-HTTP-01 challenge 는 80 port 점유 필요** — Keycloak 이 80 을 안 쓰는지 확인(충돌 시 발급 실패). Caddy/certbot 공통(D2/D3).
|
||||
- **certbot.timer 비활성** — Ubuntu default enable 이나 minimal 이미지에서 누락 → cert 만료 사고(D3). → Claims To Verify §certbot.timer.
|
||||
- **Caddy HSTS 오인** — "default HSTS" 가정 시 실제 미적용 가능(`CADDY-AHTTPS-C11` 부재 근거) → `header` directive 명시로 방어(구현 가이드 §2).
|
||||
- **HSTS preload 되돌리기 불가** — 한 번 등록 시 subdomain 전체 HTTPS 강제 PERMANENT → 학습 도메인 preload 금지(D5, `OWASP-HSTS-C6` 계열).
|
||||
- **Cloudflare Tunnel origin verification** — edge 는 self-signed origin 도 허용하나 학습은 HTTP origin 이 단순(D1).
|
||||
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-keycloak-reverse-proxy-headers]] 의 **D1 (`KC_PROXY_HEADERS=xforwarded`)** 에 의존 — TLS 종단 위치가 `X-Forwarded-Proto: https` 의 *출처*를 결정한다. Cloudflare Tunnel(D1)은 origin 이 HTTP 이므로 edge 가 헤더를 주입해야 Keycloak 이 HTTPS 인식; Caddy(D2)/nginx(D3)는 proxy 가 종단하며 헤더를 세팅. **그 계약이 바뀌면(예: `forwarded` 모드 전환)** 본 branch 의 종단-위치별 헤더 주입 가정이 깨진다.
|
||||
- [[raw/branch-notes/feature-keycloak-public-domain-tunneling]] 에 의존 — D1(Cloudflare Tunnel edge TLS)은 이 branch 의 public 도메인/tunnel 확보를 전제한다. tunnel 미확보면 D1 → D2(Caddy) fallback.
|
||||
- [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]] 에 의존(역방향 — 본 branch 의 WHY) — Google OAuth 의 HTTPS `redirect_uri` 강제가 본 branch 의 존재 이유. TLS 종단이 없으면 그 정책을 충족 불가.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서나 사례는 근거지만, 내 프로젝트에서의 동작을 자동으로 보장하지 않는다.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| Caddy `Caddyfile` 1줄 (`kc.example.com { reverse_proxy localhost:8080 }`) + `auto_https on` 디폴트로 Let's Encrypt cert 자동 발급 동작 | Caddy 공식 raw 미수집 — 본 branch 의 진행 중 메모는 관행적 사실 | 실제 EC2 또는 로컬 도커에서 Caddy 구동 → DNS A 레코드 매핑 → 첫 요청 시 ACME-HTTP-01 challenge 로그 + 발급된 cert 확인 | `planned` |
|
||||
| Cloudflare Tunnel edge certificate의 발급·갱신 조건과 실패 시 운영 책임 | `CLOUDFLARE-TUNNEL-C1`/`C2` 는 outbound tunnel 동작만 보증하고 certificate lifecycle 조건은 직접 입증하지 않음 (별도 Cloudflare SSL/TLS 페이지 필요) | Cloudflare dashboard → SSL/TLS → Edge Certificates → auto-renew 정책 확인 + cert expiry 모니터링 | `needs-confirmation` |
|
||||
| certbot.timer 가 Ubuntu 디폴트로 enable + 정상 동작 | certbot 공식 raw 미수집 — 관행적 사실 | `systemctl list-timers \| grep certbot` + `certbot renew --dry-run` 실행하여 종료 코드 0 확인 | `planned` |
|
||||
| Let's Encrypt rate limit 도메인당 주 50회 | Let's Encrypt 공식 raw 미수집 — 본 branch 의 진행 중 메모는 관행적 사실 | Let's Encrypt rate limits 페이지 직접 발췌 후 `raw/official-docs/` 등록 | `planned` |
|
||||
| HSTS preload 등록 후 subdomain 전체 HTTPS 강제 + 학습 도메인 preload 금지 권고 | HSTS preload 공식 raw 미수집 — 관행적 사실 | `hstspreload.org` 정책 페이지 발췌 후 인용 보강 | `planned` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 아직 없음 (문서 단계). 실 적용 시 예상되는 함정:
|
||||
- **Let's Encrypt rate limit**: 도메인당 주 50회 cert 발급. 디버깅 반복 시 staging endpoint (`--staging`) 활용.
|
||||
- **ACME-HTTP-01 challenge** 시 80 port 점유 필요 → Keycloak이 80을 안 쓰는지 확인.
|
||||
- **certbot.timer 비활성**: Ubuntu 디폴트로 enable되어 있으나 일부 minimal 이미지에서 누락 → cert 만료 사고.
|
||||
- **Cloudflare Tunnel origin verification**: edge에서 self-signed cert origin도 허용하나 학습 환경에서는 HTTP origin이 단순.
|
||||
- **HSTS preload 등록 후 실수**: 한 번 preload에 등록되면 subdomain 전체가 HTTPS 강제 → 학습 도메인에는 preload 금지.
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/aws-acm-managed-renewal]]
|
||||
- [[raw/official-docs/caddy-automatic-https-docs]]
|
||||
- [[raw/official-docs/certbot-user-guide]]
|
||||
- [[raw/official-docs/keycloak-hostname-configuration]]
|
||||
- [[raw/official-docs/keycloak-server-containers-docker]]
|
||||
- [[raw/official-docs/owasp-hsts-cheat-sheet]]
|
||||
- [[raw/official-docs/rfc8996-tls10-tls11-deprecation]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
> 본 branch 는 leaf — 자식 자료 없음. errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 오류 기록
|
||||
|
||||
- (없음)
|
||||
|
||||
### 면접 준비
|
||||
|
||||
- (없음)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 본 sub-sub-branch는 **문서까지만**. 실 Caddy / nginx 설치 / cert 발급은 P3A 완료 후 선택적 확장.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: 해당 없음 (문서 단계, P3B 전체 `documented-only`)
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목: 없음
|
||||
- `locally-verified` 항목: 없음
|
||||
- `prod-verified` 항목: 없음
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
- 본 sub-sub-branch 전체가 `documented-only`. 추후 `wiki/concepts/keycloak-deployment-patterns.md` 합성 시 "HTTPS termination 옵션 비교표"로 인용 후보.
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
---
|
||||
title: branch / feature-keycloak-idp-brokering-google-client (Keycloak IdP brokering — Google client 등록)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-PATTERNS-OVERVIEW-015
|
||||
kind: project-work-item
|
||||
project: keycloak-patterns-overview
|
||||
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-015
|
||||
inherits: [DEC-KEYCLOAK-PATTERNS-OVERVIEW-IDP-BROKERING-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-KEYCLOAK-PATTERNS-OVERVIEW-003]
|
||||
contract_packet: 1
|
||||
branch: feature-keycloak-idp-brokering-google-client
|
||||
parent_branch:
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, p1b, idp-brokering, google-oidc, oauth-client]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: d51e0ff4b2d65e1ea7a2b7023c4bca6352d3d0351991bf7928027836c6a8741f
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-idp-brokering-google-client (Keycloak IdP brokering — Google client 등록)
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/project-notes/keycloak-patterns-overview]]의 `WI-KEYCLOAK-PATTERNS-OVERVIEW-015` 직접 branch.
|
||||
> 학습 노트: P1B는 `documented-only` (실 구현 안 함).
|
||||
> `status_label`: `in-progress`
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/project-notes/keycloak-patterns-overview]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: Google login·Keycloak user mapping 200과 application diff 0줄이 검증된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-IDP-BROKERING-001@1` | Google federation은 Keycloak IdP brokering과 hardened First Broker Login을 사용한다 | Google OAuth client와 Keycloak Identity Provider 연결에 적용한다 | [[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 -->
|
||||
## 목표
|
||||
|
||||
Keycloak이 Google을 외부 IdP로 broker하려면 두 단계 설정이 선행돼야 한다.
|
||||
|
||||
1. **Google Cloud Console**에서 OAuth 2.0 client 생성 (`client_id`, `client_secret`, redirect URI 등록).
|
||||
2. **Keycloak Admin Console**의 `Identity Providers`에 Google provider 등록 (discovery URL + client credential 입력).
|
||||
|
||||
본 노트는 이 두 단계 설정 항목과 함정(특히 `trustEmail`)을 정리한다.
|
||||
|
||||
**핵심 통찰:**
|
||||
- Google OAuth client는 **redirect URI를 정확히 일치시켜야** 함 — Keycloak broker callback URL (`https://<kc-host>/realms/<realm>/broker/google/endpoint`)
|
||||
- `trustEmail = false`를 **명시 설정**하는 것이 채택값이다. default 값 자체는 미확정이다. 이 값은 [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D4의 silent auto-link 방어에 defense-in-depth로 결합한다.
|
||||
- Keycloak은 `discoveryURL` 한 줄만 입력하면 Google `authorize` / `token` / `userinfo` / `jwks_uri` endpoint를 자동 발견 (OIDC discovery 표준).
|
||||
|
||||
> ⚠️ **2026-07-16 `/branch-spec` 조사 정정 (원 프레이징 verbatim 보존, 정정만 surface)**: 위 "`trustEmail = false`가 **기본값**이자 권장값" 중 *권장값* 부분은 이번 조사로 근거 확보([[raw/official-docs/keycloak-identity-provider-trust-email-official]] — Google 같은 self-service IdP 에선 Keycloak 자체 email 검증을 우회하지 않는 `false` 가 안전, D6). 그러나 *기본값* 부분은 **공식 문서가 default 를 명시하지 않아 미확정**(`needs-confirmation`) — Keycloak Admin UI 신규 IdP 생성 폼 캡처로만 확정 가능. 상세는 §Decision Evidence Map D6 Open Risk + §Audit & Findings.
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- Keycloak Admin Console 의 Google IdP **등록 경로 + credential 입력** (D1·D2) — nav path, `Client ID`/`Client Secret`.
|
||||
- **`discoveryURL` 로 OIDC endpoint 자동 발견** (D5) — 본 branch 고유 owned(형제 redirect-uri-policy 미포함).
|
||||
- **`trustEmail` 값 정책** (D6) — 본 branch **core owned**. 형제 [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D4 + [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] Scenario C 가 이 값을 defense-in-depth 로 consume(역방향 의존).
|
||||
- scope `openid profile email` 최소권한 정책 (D4).
|
||||
- Google Cloud Console OAuth 2.0 Web application client **등록 필드**의 요지 (D2·D3) — 단 redirect URI exact-match 규칙·byte-level 정의·다환경 URI·consent-screen verification 상세는 형제 redirect-uri-policy 로 **위임**(§구현 가이드 §3, §엣지·실패·의존).
|
||||
- **환경별 OAuth client/project 분리 + credential never-commit 정책** (D7) — project 분리는 조건부, credential 보안은 무조건부(§D7 Open Risk).
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- **실제 Google Cloud project 생성 / OAuth client 등록 / Keycloak 실 구성** — 본 sub-sub-branch 는 `documented-only` 학습 노트(코드 repo `/home/donghyeon/workspace/keycloak-patterns/` 미생성, §Audit `NO_GROUND_TRUTH`).
|
||||
- **redirect URI exact-match 규칙 / byte-level 정의 / URL 변경 시 갱신 절차 / consent-screen verification 심사** → [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]] (deep owner, GOOGLE-REDIR 계열 근거 5개 보유).
|
||||
- **First Broker Login Flow authenticator *구성*(Confirm Link / Verify / AutoLink step)** → [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] (owner; 본 branch D6=`trustEmail` 을 defense-in-depth 로 consume).
|
||||
- **Account linking primary key (`sub` vs email) 선택** → [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] D1.
|
||||
- **Google claim → attribute mapper 구성 / Sync Mode 값 선택** → [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] (본 D6 의 FORCE 재평가 상호작용이 그 Sync Mode 값에 의존).
|
||||
- **`email_verified=false` hard-reject 전용 custom SPI authenticator** → hub §5 Deferred(server-side SPI 트랙).
|
||||
- 비-Google IdP / SAML federation.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
- [[raw/official-docs/keycloak-google-idp-setup]] — Keycloak Google IdP 등록 공식 절차
|
||||
- [[raw/official-docs/google-openid-connect-oidc]] — Google OIDC discovery / scope / claim 표준
|
||||
- [[raw/official-docs/keycloak-identity-brokering-overview-official]] — Keycloak Identity Broker 공식 개요
|
||||
- [[raw/official-docs/keycloak-identity-provider-trust-email-official]] — `Trust Email` 필드 공식 의미(ON 시 realm email 검증 skip, `email_verified` claim 기반 (un)marking, Sync Mode `FORCE` 상호작용). D6 (`trustEmail=false` 유지) 의 verbatim 근거 — 단 default 값은 이 자료로 증명 안 됨(`needs-confirmation` 잔존)
|
||||
- [[raw/official-docs/google-oauth2-policies-environment-separation-official]] — Google OAuth 2.0 Policies 공식 문서. D7 (환경별 OAuth client/project 분리 + credential never-commit 규칙) 의 verbatim 근거 — 단 project 분리 의무는 "production" app 정의 충족 시에만 조건부 적용(현재 개인 학습 단계에는 미적용 가능성, 상세는 해당 raw 의 Usage Boundaries 참조)
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급. 본 P1B sub-sub-branch는 전체 `documented-only` / `planned`.
|
||||
|
||||
- [ ] Google Cloud Console에서 OAuth 2.0 Client ID 생성 절차 정리 — 등급: `planned`
|
||||
- Application type: `Web application`
|
||||
- Authorized JavaScript origins: [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]] D6 — server-side brokering에서는 비움
|
||||
- Authorized redirect URIs: `https://<kc-host>/realms/<realm>/broker/google/endpoint`
|
||||
- [ ] Keycloak Admin Console에서 Google IdP 등록 절차 정리 — 등급: `planned`
|
||||
- `Identity Providers` → `Add provider` → `Google`
|
||||
- `Client ID` / `Client Secret`: Google Console에서 발급한 값
|
||||
- `Default Scopes`: `openid profile email` (Google OIDC 표준)
|
||||
- [ ] `discoveryURL`로 OIDC endpoint 자동 발견 검증 — 등급: `planned`
|
||||
- `https://accounts.google.com/.well-known/openid-configuration`
|
||||
- 자동 발견 시 endpoint 수동 입력 불필요 (`authorize_endpoint`, `token_endpoint`, `userinfo_endpoint`, `jwks_uri` 자동 채움)
|
||||
- [ ] `trustEmail` 옵션 정책 결정 — 등급: `documented-only`
|
||||
- 기본값 `false` 유지 (보안 위험 회피)
|
||||
- Google이 `email_verified=true` claim 제공 시에만 Keycloak이 email verified로 인정
|
||||
- [ ] `Display Name on Login Page` 설정 — 등급: `planned`
|
||||
- 사용자에게 보이는 버튼 라벨 (예: "Sign in with Google", "Google로 로그인")
|
||||
- [ ] 환경별 OAuth client 분리 정책 정리 — 등급: `documented-only`
|
||||
- dev / staging / prod 환경별 별도 OAuth client → redirect URI 충돌 방지
|
||||
- Google Cloud Console의 client당 redirect URI 등록은 1:1 일치 필요
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- Google OAuth client 생성 시 OAuth consent screen 설정도 필요 (앱 이름, 로고, scope 목록). 내부 사용자만 대상이면 `Internal` (Google Workspace 도메인) / 외부 공개면 `External` + verification 절차.
|
||||
- discovery URL이 동작하면 Keycloak admin UI에서 endpoint 입력 필드가 read-only로 회색 처리되는 것을 확인 (Keycloak 25.x).
|
||||
- redirect URI mismatch는 가장 흔한 함정 — Google Console 등록값과 Keycloak broker endpoint URL이 정확히 같아야 함 (trailing slash, scheme 포함).
|
||||
- `Sync Mode` 옵션 (`IMPORT`, `LEGACY`, `FORCE`)은 매핑 단계에서 다룸 → [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]].
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
> 본 sub-sub-branch는 `documented-only` — 실 환경 결정 없음. **만약 구현한다면** 권장 기본값.
|
||||
|
||||
- 2026-05-25: `trustEmail = false` 유지 (보안 위험 회피). Google `email_verified=true` claim에만 의존.
|
||||
- 2026-05-25: `discoveryURL` 사용 (수동 endpoint 입력 대신). Google이 endpoint URL 변경 시 자동 대응.
|
||||
- 2026-05-25: 환경별 OAuth client 분리 (dev/staging/prod). 단일 client 공유 시 redirect URI 충돌 + secret 노출 범위 확대 위험.
|
||||
- 2026-05-25: scope `openid profile email`만 요청 (최소 권한). 추가 scope 요청 시 Google OAuth verification 트리거 가능.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 본 sub-sub-branch 는 `documented-only`. cited raw sources: `keycloak-google-idp-setup`, `google-openid-connect-oidc`, `keycloak-identity-brokering-overview-official`, `keycloak-identity-provider-trust-email-official`(D6), `google-oauth2-policies-environment-separation-official`(D7).
|
||||
>
|
||||
> `선택 조건` 열(R2): "이 조건일 때 이 결정, 다른 조건이면 어떤 대안". 분기 없으면 `N/A`.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | Keycloak Admin Console 에서 Google IdP 등록 시 `Identity Providers` → `Add provider` → `Google` 경로 사용 | Keycloak 내장 Google social provider 사용 시 항상 이 경로. 대안: generic `OpenID Connect v1.0` provider (목록에 없는 IdP 또는 커스텀 endpoint 를 직접 지정해야 할 때) | `raw/official-docs/keycloak-google-idp-setup.md#KC-GIDP-C1` ("go to the `Identity Providers` left menu item and select `Google` from the `Add provider` drop down list") | `official-vendor-doc` | Keycloak admin console v2 (Keycloak 19+) 의 동일 경로 navigation 검증 필요 |
|
||||
| D2 | Google Cloud Console 에서 OAuth 2.0 Web application client 생성, `Client ID` + `Client Secret` 발급 후 Keycloak 에 입력 | Keycloak 이 server-to-server 로 Google `/token` 호출(confidential client, secret 보관) → 항상 **Web application** type. 대안(Android/iOS/Desktop/limited-input client type)은 그 플랫폼에서 직접 도는 OAuth client 일 때만 (`GOOGLE-OAUTHPOLICY-C4` platform 별 분리) | `raw/official-docs/keycloak-google-idp-setup.md#KC-GIDP-C2` ("you'll need to obtain a `Client ID` and `Client Secret` from Google") | `official-vendor-doc` | Google Cloud Console UI 의 정확한 OAuth consent screen 설정 절차는 본 인용 범위 밖 |
|
||||
| D3 | Authorized redirect URIs 에 `https://<kc-host>/realms/<realm>/broker/google/endpoint` 등록 — Keycloak 측 발급값 그대로 복사 | 항상 Keycloak 이 표시하는 Redirect URI 를 그대로 복사(Google exact-match 요구, 분기 없음 = N/A). exact-match 규칙·다환경 URI·byte-level 정의는 형제 [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]] D1~D4 가 deep owner (본 D3 는 등록 step 만, §Audit `SINGLE_OWNER_TENSION`) | `raw/official-docs/keycloak-google-idp-setup.md#KC-GIDP-C3` ("One piece of data you'll need from this page is the `Redirect URI`") + `raw/official-docs/keycloak-google-idp-setup.md#KC-GIDP-C4` ("You'll also need to copy and paste the `Redirect URI` from the Keycloak `Add Identity Provider` page into the `Authorized redirect URIs` field") | `official-vendor-doc` | redirect URI 정확한 path 형식 (`/realms/<realm>/broker/google/endpoint`) 의 vendor verbatim 부재 — admin UI 자동 표시값 신뢰 |
|
||||
| D4 | scope `openid profile email` 만 요청 (최소 권한) | 인증·식별만 필요(학습) → `openid profile email` 최소 scope. Google API(Gmail/Drive 등) 접근이 필요할 때만 scope 확장 → 단 sensitive/restricted scope 는 Google verification 심사 유발 | `raw/official-docs/keycloak-google-idp-setup.md#KC-GIDP-C5` ("By default, Keycloak uses the following scopes: `openid` `profile` `email`") + `raw/official-docs/google-openid-connect-oidc.md#GOIDC-C4` (`email` claim 은 `email` scope 포함 시에만 제공) | `official-vendor-doc` | Google OAuth verification 트리거의 정확한 조건 (sensitive scope 목록) 은 cited raw 에 verbatim 없음 |
|
||||
| D5 | `discoveryURL` 사용 (수동 endpoint 입력 대신) — `https://accounts.google.com/.well-known/openid-configuration` | IdP 가 well-known OIDC discovery config 를 게시(Google=제공) → discoveryURL 로 endpoint 자동 발견. 대안(수동 endpoint 4개 입력)은 discovery 미제공 IdP 또는 endpoint 를 명시적으로 pin/override 해야 할 때만 | `raw/official-docs/google-openid-connect-oidc.md#GOIDC-C5` ("The Discovery document for Google's OpenID Connect service may be retrieved from: `https://accounts.google.com/.well-known/openid-configuration`") | `official-vendor-doc` | Keycloak admin UI 가 discovery URL 입력 시 endpoint 필드를 자동 채우는지의 verbatim 인용 없음 — UI 캡처 검증 필요 |
|
||||
| D6 | `trustEmail = false` 유지 — Google 같은 self-service 소비자 IdP 의 email 을 무조건 verified 로 신뢰하지 않고 Keycloak/flow 의 자체 검증을 유지 | IdP 가 Google 같은 **self-service 소비자 OAuth**(자기신고 email 존재) → `false`(Keycloak 자체 verification 유지). `true` 대안은 조직이 완전 통제하는 **enterprise SSO**(email_verified 구조적으로 항상 신뢰)에서만 — 본 Google 시나리오엔 부적합. 조건부 신뢰(email_verified 일 때만 skip)는 Keycloak **공식 미지원**(GitHub #8622 미병합) → custom SPI 필요, 학습 범위 밖 | `raw/official-docs/keycloak-identity-provider-trust-email-official.md#KC-TRUSTEMAIL-C1` (ON=realm email 검증 skip), `#KC-TRUSTEMAIL-C2` (`email_verified` 기반 (un)marking), `#KC-TRUSTEMAIL-C3` (Sync Mode `FORCE` 시 매 로그인 재평가) — 값 선택(=OFF)의 의미론적 근거 확정 | `official-vendor-doc` (값 선택 근거) — 단 **`default=false` 서브클레임은 `needs-confirmation`** | **공식 문서가 `trustEmail` 의 default 값을 명시하지 않음** — "false 가 기본값"은 미확정, Keycloak Admin UI(25.x/26.x) 신규 IdP 폼 캡처로만 확정(Claims To Verify). 값이 안전하다는 결론은 First Broker Login Confirm Link flow 무결성에 의존하나, **CVE-2026-9087**(cross-session verification proof not bound to upstream identity; 26.3.0~26.6.1 + main, patched 2026-06-02 PR #49513)은 `trustEmail=false`+email 인증 상태에서도 우회가 있었음을 보임 → 형제 first-broker-login-flow core 방어에 버전 caveat 필요(§Audit `CVE_CROSS_BRANCH`). `true` 의 (un)marking 재평가는 community Issue #39885(FORCE 버그, 비공식)로 신뢰도 낮음 — 근거 인용 금지 |
|
||||
| D7 | 환경별 OAuth client/project 분리 (dev/staging/prod) — 단일 client 공유 시 redirect URI 충돌 + secret 노출 확대. **credential 은 public repo 에 절대 커밋 금지, secret manager 취급** | 현재(`documented-only`·실사용자 0) → **단일 client + 다중 redirect URI**(운영 부담 최소, blast-radius 우려는 실사용자 부재로 공허). Google "production"(C2) 기준 충족(실배포 tier·실사용자 >100 or 공개) → **환경별 별도 project**(C1 의무 발동, client 자동 분리). 과도기(팀원 dev 접근) → **별도 client(같은 project)**. credential never-commit(C3)은 단계 무관 **항상** | `raw/official-docs/google-oauth2-policies-environment-separation-official.md#GOOGLE-OAUTHPOLICY-C1` ("you must create separate projects in the Google Cloud Console for each deployment tier, such as development, staging, and production") + `#GOOGLE-OAUTHPOLICY-C3` ("You must never commit client credentials into publicly available code repositories") | `official-vendor-doc` (조건부 — 아래 Open Risk 참조) | **환경별 project 분리(C1) 는 Google 이 정의하는 "production" app 요건(`#GOOGLE-OAUTHPOLICY-C2`: 공유 안 함 또는 100명 미만 개인적으로 아는 사람 → personal use 로 예외) 충족 시에만 의무.** 본 branch 는 현재 `documented-only` 개인 학습 프로젝트라 이 조건을 충족하지 못해 project 분리가 "지금 당장의 공식 의무"는 아님 — 실 배포/실사용자 확대 시점부터 발동되는 **선제적 설계 근거**로만 인용. 반면 credential never-commit(C3) 은 production 스코프 밖 규정이라 지금부터 무조건 적용 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> *결정(Decisions)* 이 "*무엇*" 이라면 본 §는 "*어디에 어떻게*" 의 사전 명세. 본 branch 는 `documented-only` 학습 노트이므로 anchor 는 **공식 문서가 규정하는 필드·값**이고, 코드/Admin UI 로만 확인되는 것은 `planned`/`UNSUPPORTED_IMPL_DECISION` 로 표기한다(코드 repo `/home/donghyeon/workspace/keycloak-patterns/` 미생성, §Audit `NO_GROUND_TRUTH`).
|
||||
> 본 branch owned 구현 대상은 **Keycloak Admin 측 Google IdP 등록(D1·D2·D5·D6)** 이 핵심이고, Google Console 측(D2·D3·D4·D7)은 요지만 두고 exact-match/consent verification 깊이는 형제 [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]] 로 **위임**(재진술 안 함, Single-Owner).
|
||||
|
||||
### 1. Keycloak Admin Console — Google IdP 등록 입력 매핑 (D1·D2·D5·D6)
|
||||
|
||||
> **Trace**: D1(`KC-GIDP-C1` nav) · D2(`KC-GIDP-C2` credential 입력) · D5(`GOIDC-C5` discovery) · D6(`KC-TRUSTEMAIL-C1` Trust Email).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: Keycloak 25.x/26.x admin UI 의 정확한 필드 라벨·위치(`Use discovery endpoint` 토글 명칭, `Trust Email` 토글 위치, discovery 입력 시 endpoint 필드 read-only 여부)는 코드/UI 미확인 → `planned`. trade-off: 학습 단계엔 `planned`, Admin UI 캡처(Claims To Verify) 시 확정.
|
||||
|
||||
| 단계 / 필드 | 값 / 명세 | Trace |
|
||||
|---|---|---|
|
||||
| IdP 추가 | Identity Providers → Add provider → **Google** (alias=`google`) | D1, `KC-GIDP-C1` |
|
||||
| Client ID / Client Secret | Google Console 발급값 입력 | D2, `KC-GIDP-C2` |
|
||||
| Use discovery endpoint | ON → `https://accounts.google.com/.well-known/openid-configuration` 입력 → authorize/token/userinfo/jwks endpoint 자동 발견 | D5, `GOIDC-C5` |
|
||||
| Default Scopes | `openid profile email` (기본값 유지) | D4, `KC-GIDP-C5` |
|
||||
| Trust Email | **OFF (`false`)** 명시 설정 (§2) | D6, `KC-TRUSTEMAIL-C1` |
|
||||
| Display Name on Login Page | 로그인 버튼 라벨 (예: "Sign in with Google") — `UNSUPPORTED_IMPL_DECISION` (cosmetic 표시값, 동작·보안 무관 → 임의) | TODO 참조 |
|
||||
|
||||
### 2. `trustEmail` 필드 — ON/OFF 의미 + 값 선택 (D6)
|
||||
|
||||
> **Trace**: D6 + `KC-TRUSTEMAIL-C1`/`C2`/`C3`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: default 값이 이미 OFF 인지 **공식 문서 미명시**(fetch 한 `configuration.adoc` 에 default 문장 부재, self-grep 확인) → Admin UI 캡처로만 확정. trade-off: default 불확실성을 피하려면 **OFF 를 명시적으로 설정**(권장) — default 에 의존하지 않음.
|
||||
|
||||
| 상태 | 동작 | 근거 |
|
||||
|---|---|---|
|
||||
| ON (`true`) | IdP 제공 email 을 신뢰 → realm email 검증 skip; IdP 가 email 검증 여부를 advertise(예: `email_verified`)하면 그 값으로 (un)marking; Sync Mode `FORCE` 면 매 로그인 재평가 | `KC-TRUSTEMAIL-C1`/`C2`/`C3` |
|
||||
| OFF (`false`, **채택**) | realm email 검증 절차 유지 — Google email 을 무조건 verified 로 신뢰하지 않음 (ON 의 반대 함의) | `KC-TRUSTEMAIL-C1` |
|
||||
| default | **미확정** — 공식 문서 미명시 → OFF 를 명시 설정 권장 | `UNSUPPORTED_IMPL_DECISION` |
|
||||
|
||||
### 3. Google Cloud Console — client 등록 요지 (D2·D3·D4·D7) + 형제 위임
|
||||
|
||||
> **Trace**: D2(`KC-GIDP-C2`) · D3(`KC-GIDP-C3`/`C4`) · D4(`KC-GIDP-C5`) · D7(`GOOGLE-OAUTHPOLICY-C1`/`C3`).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: JavaScript origins 비움 여부·redirect URI byte-level(trailing slash/case)·consent-screen verification 심사·URL 변경 갱신 절차는 형제 [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]] §1 이 owner — **여기서 재진술하지 않음**(Single-Owner, §Audit `SINGLE_OWNER_TENSION`).
|
||||
|
||||
| 필드 | 값 | Trace | Note |
|
||||
|---|---|---|---|
|
||||
| Application type | **Web application** | D2 | server-to-server confidential client |
|
||||
| Authorized redirect URIs | `https://<kc-host>/realms/<realm>/broker/google/endpoint` (Keycloak 표시값 그대로 복사) | D3, `KC-GIDP-C3`/`C4` | exact-match·다환경 URI·JS origins → 형제 redirect-uri-policy §1 위임 |
|
||||
| Scopes (consent) | `openid email profile` | D4, `KC-GIDP-C5` | verification 심사 상세 → 형제 redirect-uri-policy D5 |
|
||||
| 환경 분리 단위 | 현재 단일 client / 실배포 tier 발생 시 별도 project | D7, `GOOGLE-OAUTHPOLICY-C1` | project 분리는 조건부(Google "production" 정의 충족 시). credential never-commit(`C3`)은 **항상** |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4 캡처용. 정상 등록 경로 외에 *구현 중 부딪힐* 실패/엣지 + 본 branch 가 consume/제공하는 다른 계약. 실 적용 전이므로 "예상" 경로.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **redirect_uri mismatch (D3)**: Google 등록값 ≠ Keycloak broker endpoint(trailing slash / scheme / path 오타) → Google `redirect_uri_mismatch` 로 인증 차단. 기대: Keycloak 표시값 그대로 복사. byte-level 상세·갱신 절차 → 형제 redirect-uri-policy.
|
||||
- **discovery 실패 / endpoint 변경 (D5)**: Google `.well-known` 미응답 또는 endpoint URL 변경 시 broker token 교환 실패. Keycloak 의 discovery 캐시/재fetch 주기는 미확인(`needs-confirmation`).
|
||||
- **`trustEmail=true` 오설정 (D6)**: IdP email을 무조건 신뢰하면 realm 자체 검증이 건너뛰어질 수 있다. core 방어는 [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D4의 silent auto-link 차단이며 본 D6는 defense-in-depth다. First Broker Login의 버전별 보안 caveat는 공식 advisory가 raw로 보존되기 전까지 `needs-confirmation`으로만 취급한다.
|
||||
- **Trust Email + Sync Mode `FORCE` 상호작용 (D6)**: `FORCE` 면 매 로그인마다 email verified 재평가(`KC-TRUSTEMAIL-C3`) → Sync Mode 값 owner 형제 [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D3 에 의존. `IMPORT`/`LEGACY` 재평가 여부는 이 자료 미명시.
|
||||
- **`client_secret` 노출 (D7)**: Keycloak DB plaintext + docker-compose env → git 커밋 누출 위험. 기대: never-commit(`GOOGLE-OAUTHPOLICY-C3`) + vault/secret manager. **단일 client 공유 시 유출 blast radius 가 전 환경**.
|
||||
- **`trustEmail` default 불확실 (D6)**: 명시 설정 없이 default 에 의존하면 버전별 default 상이 위험 → **OFF 를 명시 설정**해 회피.
|
||||
- **다른 계약 의존**:
|
||||
- (본 branch 가 **제공** → 역방향 consume) [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] `D4` 가 본 `D6`(trustEmail=false)를 defense-in-depth 로 consume. 본 D6 값이 바뀌면 그 방어 전제 변함.
|
||||
- (제공) [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] Scenario C 가 본 `D6` 를 `email_verified=false` takeover 방어 입력으로 consume.
|
||||
- (consume) [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]] `D1`~`D4` — redirect URI exact-match·다환경 URI 의 deep owner. 본 `D3` 는 그 등록 결과(Keycloak Redirect URI → Google)를 사용.
|
||||
- (consume) [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] `D3`(Sync Mode) — D6 의 `FORCE` 재평가 상호작용이 그 값에 종속.
|
||||
- (consume) [[raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation]] (부모) — broker endpoint host(`<kc-host>`) 를 결정. host 가 바뀌면 D3 redirect URI 도 재등록 필요.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 본 sub-sub 가 `documented-only` 라도, 만약 P3B 구현 시점에 도달하면 검증해야 할 주장.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| Keycloak 25.x admin UI 의 정확한 Google IdP 등록 navigation 경로 | cited `keycloak-google-idp-setup` 은 legacy gitbook 미러; 신규 UI 와 차이 가능 | Keycloak 25.x docker 컨테이너 실행 후 admin UI 캡처 | `needs-confirmation` |
|
||||
| `https://<kc-host>/realms/<realm>/broker/google/endpoint` 가 Keycloak 의 정확한 callback URL 형식 | path 형식의 vendor verbatim 부재 | Keycloak admin UI 의 "Redirect URI" 자동 표시값 캡처 + Server Admin Guide raw 발췌 보강 | `needs-confirmation` |
|
||||
| `trustEmail = false` 가 Keycloak Google IdP 의 default 값 | cited raw 에 verbatim 부재 | Keycloak 25.x docker 실행 + admin UI 에서 default toggle 상태 캡처 | `needs-confirmation` |
|
||||
| discovery URL 입력 시 Keycloak admin UI 가 endpoint 4개 (authorize/token/userinfo/jwks) 를 자동 채움 | UI 동작의 verbatim 인용 없음 | discovery URL 입력 후 endpoint 필드가 read-only / 자동 채워지는지 admin UI 캡처 | `needs-confirmation` |
|
||||
| Google `email_verified` claim 이 항상 true 인 사용자만 신뢰 가능 (자동 link 허용) | Google `email_verified` 의 보장 수준 verbatim 인용이 `google-openid-connect-oidc` 에 부재 (C4 는 `email` claim 만 다룸) | Google OIDC claims table 추가 발췌 + `email_verified=false` 시나리오 (예: Gmail unverified alias) 테스트 | `needs-confirmation` |
|
||||
| 환경별 project 분리 의무(`GOOGLE-OAUTHPOLICY-C1`)가 이 학습 프로젝트에 실제로 집행되는가 — Google 이 "production" 정의 미충족(personal use) 앱에도 verification-review 에서 project 미분리를 지적하는지 | 공식 문서는 "production" app 에만 의무로 명시(`GOOGLE-OAUTHPOLICY-C2` personal-use 예외) — 실제 집행 관행은 문서 범위 밖 | Google Trust & Safety verification 절차 raw 추가 또는 실제 Testing→Published 전환 시 관찰 | `needs-confirmation` |
|
||||
| GCP OAuth consent screen 이 project 레벨 리소스여서 별도 client(같은 project) 로는 env 별 branding 분리가 안 되는가 (D7 Alt1 vs Alt3 차별점) | D7 조사에서 구조적 추론으로만 제시(verbatim 부재) | GCP Console 에서 같은 project 의 2 client 가 consent screen 을 공유하는지 실제 확인 | `needs-confirmation` |
|
||||
|
||||
## Audit & Findings
|
||||
|
||||
> 2026-07-16 `/branch-spec` 자동조사(공식 문서 web 조사 + 형제 corpus 정독) 결과. 사용자 작성 결정/메모는 verbatim 보존, 아래는 정합 권고·정정·근거 승급만 (CLAUDE.md §11).
|
||||
|
||||
- **D6_UPGRADE (UNSUPPORTED → official-vendor-doc, 값 선택분만)**: D6 `trustEmail=false` 의 *값 선택* 근거를 [[raw/official-docs/keycloak-identity-provider-trust-email-official]] (`KC-TRUSTEMAIL-C1~C3`: ON=realm 검증 skip / `email_verified` (un)marking / `FORCE` 재평가)로 승급. **단 "false 가 default" 서브클레임은 여전히 `needs-confirmation`** — fetch 한 `configuration.adoc` 에 default 문장 부재(self-grep `default` = Trust Email 무관 1건뿐). 목표/WHY 의 "기본값이자 권장값" 중 *권장값* 만 grounded, *기본값* 은 미확정(§목표 정정 callout). 조건부 신뢰(email_verified 일 때만 skip)는 Keycloak 공식 미지원(GitHub #8622 미병합) — custom SPI 필요, 학습 범위 밖.
|
||||
- **D7_RESCOPE (UNSUPPORTED → official-vendor-doc 조건부)**: Google 공식(`GOOGLE-OAUTHPOLICY-C1`)이 배포 tier 별 **project** 분리를 명시하나 "production" app(`GOOGLE-OAUTHPOLICY-C2`) 스코프 — 본 개인 학습 프로젝트는 personal-use 예외라 **현재 의무 아님**(선제적 설계 근거로만 인용). credential never-commit(`GOOGLE-OAUTHPOLICY-C3`)은 production 스코프 밖이라 **무조건** 적용. 원 "환경별 OAuth **client** 분리"는 "**project** 분리(client 자동 분리 결과)"로 재프레이밍 — client(같은 project) 분리만으로는 consent-screen branding 분리 불가(구조적 추론 → Claims To Verify).
|
||||
- **UNARCHIVED_SECURITY_CANDIDATE (OUT_OF_BRANCH_SCOPE)**: 이전 조사에서 First Broker Login의 cross-session verification 취약점 후보가 기록됐지만 공식 advisory가 raw로 보존되지 않았다. 실재·영향 버전·patch 버전은 현재 `needs-confirmation`이며 FACT나 배포 하한으로 사용하지 않는다. 보존 후 owner [[raw/branch-notes/feature-keycloak-first-broker-login-flow]]에서 version gate를 결정한다.
|
||||
- **SINGLE_OWNER_TENSION (D3 vs 형제 redirect-uri-policy, Should-fix)**: D3(redirect URI 등록)은 형제 [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]] D1~D4(GOOGLE-REDIR 근거 5개 — exact-match·다환경·JS origins·consent verification 의 deep owner)와 실질 중복. 본 branch 는 "Keycloak Redirect URI → Google 복사" **등록 step** 만 유지하고 exact-match/byte-level/갱신 depth 는 그 형제로 위임(§구현 가이드 §3 reference-only). 사용자 결정 영역이라 자동 rewrite 안 함 — `/sync` 대조 권고.
|
||||
- **STALE_REVERSE_REF (Should-fix, `/sync` 대상)**: D6 가 `UNSUPPORTED_DECISION` → `official-vendor-doc`(값 선택분) 로 승급됐으므로, 본 D6 를 "자체 `UNSUPPORTED`"로 요약·의존하는 역참조들이 부분 stale: [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] (L27·L65·L80·L135·L169·L196 + DEPTH_LOOP_1 F3), [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] (L206·L214). **의미는 안전한 방향으로만 바뀜**(UNSUPPORTED → grounded; 두 형제의 "core 방어는 trustEmail 과 무관" 논리는 그대로 유효) → 비차단. consistency-contract §전파 precedent(account-linking 자신의 D3 승급을 `/sync` 로 남긴 것)와 동일하게 **이번 fill 에선 형제 재작성 안 하고 `/sync` 로 위임**. `default=false` 는 여전히 needs-confirmation 이라 형제들의 "trustEmail 값 확정은 그 branch" 서술은 *부분적으로만* 갱신 필요.
|
||||
- **NO_GROUND_TRUTH (한계 명시)**: 코드 repo `/home/donghyeon/workspace/keycloak-patterns/` 미생성 → `actually-implemented` 주장 불가, 모든 구현 detail `planned`/`needs-confirmation`. §참조의 ca-tmpl ground truth(registries/error-codes 등)는 **본 프로젝트와 무관**(keycloak-patterns = 별도 repo) — 계약값 검증 대상 아님. coverage 게이트도 면제(`governing_docs` 미지정 + related_projects=keycloak-patterns, `rules/coverage-gate.md` §7).
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 아직 없음(문서 단계).
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/google-oauth2-policies-environment-separation-official]]
|
||||
- [[raw/official-docs/google-oidc-discovery-spec]]
|
||||
- [[raw/official-docs/google-openid-connect-oidc]]
|
||||
- [[raw/official-docs/keycloak-google-idp-setup]]
|
||||
- [[raw/official-docs/keycloak-identity-broker-spi]]
|
||||
- [[raw/official-docs/keycloak-identity-brokering-overview-official]]
|
||||
- [[raw/official-docs/keycloak-identity-provider-trust-email-official]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
- [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]]
|
||||
- [[raw/branch-notes/feature-keycloak-three-leg-trust-chain]]
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
> 본 sub-sub-branch 는 leaf — 자식 자료 없음. Phase 3 P3A 실 구현 또는 외부 산출물 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 근거 자료
|
||||
|
||||
- [[raw/official-docs/keycloak-google-idp-setup]]
|
||||
- [[raw/official-docs/google-openid-connect-oidc]]
|
||||
- [[raw/official-docs/keycloak-identity-brokering-overview-official]]
|
||||
- [[raw/official-docs/keycloak-identity-provider-trust-email-official]]
|
||||
- [[raw/official-docs/google-oauth2-policies-environment-separation-official]]
|
||||
|
||||
### 오류 기록 (이 sub-sub-branch 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 documented-only 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — Phase 3 실 구현 단계에 누적)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크: (없음, 문서까지만)
|
||||
- 머지 결과 / 배포 환경: 없음 (`documented-only`)
|
||||
- **wiki 추출 대상:** 없음. P1B sub-sub-branch는 root 정책상 wiki/projects/ 승급 안 함.
|
||||
- **추출하지 않을 항목:** 본 sub-sub-branch 전체 (`documented-only` / `planned`).
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
---
|
||||
title: branch / feature-keycloak-idp-mappers-claim-to-role (IdP Mappers — Google claim → Keycloak role)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-CHILD-321B472C
|
||||
kind: branch-child
|
||||
project: keycloak-patterns-overview
|
||||
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-015
|
||||
inherits: [DEC-KEYCLOAK-PATTERNS-OVERVIEW-IDP-BROKERING-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-KEYCLOAK-PATTERNS-OVERVIEW-003]
|
||||
contract_packet: 1
|
||||
branch: feature-keycloak-idp-mappers-claim-to-role
|
||||
parent_branch: feature-keycloak-idp-brokering-google-client
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, spa, idp-brokering, google-federation, idp-mappers, p2b]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 6c7b7381db7953e7ba85ecf9dfc998cce5e018e0f146a3caa4a34e978c45324a
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-idp-mappers-claim-to-role (IdP Mappers — Google claim → Keycloak role)
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/branch-notes/feature-keycloak-idp-brokering-google-client]]의 WI015 child branch.
|
||||
> 학습 노트. P2B는 `documented-only` 단계.
|
||||
|
||||
> **정합 노트 (2026-07-14 감사)**: 본 노트의 attribute-mapping 내용(Attribute Importer / Sync Mode / `hd` / `email_verified`)은 형제 [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] 와 ~70% 겹친다 — 그쪽이 attribute-mapping **owner**. 본 노트의 고유 책임 = **claim → role (RBAC 인가)** 이며 §5 **deferred authZ 트랙**. /ingest 시 attribute 부분은 owner 를 인용하고 본 노트는 role 부분만 남긴다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/branch-notes/feature-keycloak-idp-brokering-google-client]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: Google login·Keycloak user mapping 200과 application diff 0줄이 검증된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-IDP-BROKERING-001@1` | Google federation은 Keycloak IdP brokering과 hardened First Broker Login을 사용한다 | Google claim-to-role mapper를 IdP brokering 구성의 하위 계약으로 적용한다 | [[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 -->
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/keycloak-identity-provider-mappers]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
> 본 sub-sub-branch 는 leaf — 자식 자료 없음. Phase 3 P3A 실 구현 또는 외부 산출물 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 오류 기록 (이 sub-sub-branch 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 documented-only 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — Phase 3 실 구현 단계에 누적)
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
Google ID token claim을 Keycloak role로 매핑해서, **SPA / backend가 Google 출신 사용자를 Keycloak local 사용자와 동일 RBAC 모델에서** 다룰 수 있게 한다. Profile attribute 매핑은 [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]]이 정본이다.
|
||||
|
||||
면접 질문: "Google에서 받은 사용자 정보를 backend가 어떻게 보나요?"
|
||||
→ "Keycloak의 IdP role mapper로 Google claim(예: `hd`)을 role로 변환합니다. Profile attribute는 별도 owner가 매핑하고, backend는 최종 Keycloak access token의 role만 소비합니다."
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- **Hardcoded Role / Claim to Role / Advanced Claim to Role**: Google claim 값에 따른 role 부여.
|
||||
- Google `hd` claim 기반 role 분기와 개인 Gmail(`hd` 부재) 처리 정책.
|
||||
- **role mapper instance-level `Sync Mode Override = FORCE`**: IdP-level default를 바꾸지 않고 role freshness만 갱신(D1).
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- Google profile claim → user attribute, Username Template, picture 전파, IdP-level default Sync Mode → [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D2/D3/D4.
|
||||
- `email_verified=false` link 정책 → [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D4. 현재 범위는 silent auto-link 차단이며 hard-reject SPI는 별도 variant다.
|
||||
- 코드 변경 없음 검증 — [[raw/branch-notes/feature-keycloak-federation-spa-zero-change]]
|
||||
- JWT signature 검증 메커니즘 — [[raw/branch-notes/feature-keycloak-three-leg-trust-chain]]
|
||||
- Account Linking 흐름 — [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]]
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] Role mapper 종류표 작성 — Hardcoded Role / Claim to Role / Advanced Claim to Role — 등급: `documented-only`
|
||||
- [ ] Google `hd` claim → Advanced Claim to Role mapper 설정 (예: `hd=example.com`이면 `internal-user` role 부여) — 등급: `documented-only`
|
||||
- [ ] IdP-level default `IMPORT`를 유지하면서 **role mapper instance에만 `Sync Mode Override=FORCE`** 설정 — 등급: `planned`
|
||||
- [ ] `hd` 미존재 시 (개인 Gmail 계정) 처리 정책 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- Profile attribute와 token claim 전파는 [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D4 — 본 노트는 그 값을 재명세하지 않는다.
|
||||
- `hd` claim은 **Google Workspace 계정에만 존재**. 개인 Gmail 계정은 `hd` 없음. 따라서 "hd 없으면 거부"는 **사내 SaaS** 용도 (학습 노트에선 미적용).
|
||||
- IdP-level default는 [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D3의 `IMPORT`다. 본 노트의 `FORCE`는 **role mapper instance-level override**로만 적용한다.
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- 2026-07-18: **role mapper instance-level `Sync Mode Override = FORCE`** — role freshness만 갱신한다. IdP-level default는 [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D3의 `IMPORT`를 유지한다.
|
||||
- 2026-07-18: `email_verified=false` 정책은 [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D4를 consume한다. 현재 custom SPI artifact가 없으므로 전체 hard-reject를 이 branch에서 주장하지 않는다.
|
||||
- 2026-05-25: **`hd` claim 미적용** — 학습 단계는 개인 Gmail도 허용. 운영 SaaS 도입 시 Advanced Claim to Role로 `hd=example.com → internal-user` 매핑 추가.
|
||||
- 2026-05-25 (delegated): `picture` 전파는 [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D4가 소유한다.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- (학습 단계, 미실행)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
- [[raw/official-docs/keycloak-identity-provider-mappers]]
|
||||
- [[raw/official-docs/keycloak-identity-provider-sync-mode-official]]
|
||||
- [[raw/official-docs/google-oidc-discovery-spec]]
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. `Decision ID` 는 본 branch-note 안에서 안정적으로 유지.
|
||||
|
||||
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|
|
||||
| D1 | **role mapper instance-level `Sync Mode Override = FORCE`** — IdP-level default `IMPORT`와 적용 계층을 분리 | `raw/official-docs/keycloak-identity-provider-sync-mode-official.md#KC-SYNCMODE-C4` (`force` = each login update), `#KC-SYNCMODE-C5` (mapper-level override) + [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D3 — IdP-level default `IMPORT` | `official-vendor-doc + delegated` | role mapper에서 override가 노출되고 IdP default보다 우선하는지는 realm export/Admin UI 실측 전까지 `needs-confirmation` |
|
||||
| D2 | **DELEGATED** — [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D4 — 현재 정책은 silent auto-link 차단 | owner D4 | `delegated` | `email_verified=false` 전체 hard-reject는 구현된 custom SPI가 있는 별도 variant에서만 가능하며 현재 artifact 없음 |
|
||||
| D3 | `hd` claim 미적용 (학습 단계 — 개인 Gmail 허용, 운영 SaaS 전환 시 Advanced Claim to Role 매핑 추가) | `raw/official-docs/google-oidc-discovery-spec.md#GOOGLE-OIDC-C7` (`hd` 는 Workspace/Cloud organization 도메인 — personal Google account 의 `hd` 부재는 인용에 명시 없음 단서) | `official-vendor-doc` | personal Gmail 의 `hd` claim 부재 시 Keycloak mapper 동작 (null / 없음 / 거부) 의 정확한 검증은 별도 필요 — `GOOGLE-OIDC-C7` "Does not prove" 단서 |
|
||||
| D4 | **DELEGATED** — [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D4 — profile attribute 매핑 owner | owner D4 | `delegated` | 본 role branch에서 attribute/token 전파 값을 재명세하지 않음 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
| 단계 | 이 branch의 설정 | 완료 조건 |
|
||||
|---|---|---|
|
||||
| 1 | IdP-level default는 [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D3의 `IMPORT`를 그대로 사용 | realm export에서 IdP 기본값이 `IMPORT`임을 확인 |
|
||||
| 2 | Google IdP 아래 role mapper instance에만 `Sync Mode Override=FORCE`를 적용 | realm export에서 해당 mapper의 override만 `FORCE`임을 확인 |
|
||||
| 3 | 운영 SaaS variant에서만 `hd=example.com` 조건의 Advanced Claim to Role mapper를 추가 | Workspace 계정과 개인 계정의 최종 Keycloak role 차이를 token으로 검증 |
|
||||
|
||||
현재는 `documented-only`다. Admin UI 캡처, realm export, 로그인 2회 후 role 갱신 증거가 모이기 전에는 구현 완료로 승격하지 않는다.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
| 구분 | 조건 | 처리 / owner |
|
||||
|---|---|---|
|
||||
| Edge | 개인 Google 계정에 `hd`가 없을 수 있음 | 학습 variant에서는 로그인 자체를 거부하지 않고 도메인 기반 role만 부여하지 않는 정책을 검증한다 |
|
||||
| Failure | `FORCE`를 IdP-level default로 잘못 적용 | profile attribute까지 매 로그인 갱신되는 범위 확장을 피하고, role mapper instance override로 되돌린다 |
|
||||
| Dependency | profile attribute와 IdP default Sync Mode | [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D3/D4 |
|
||||
| Dependency | `email_verified=false` 연결 정책 | [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D4 |
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서나 사례는 근거지만, 내 프로젝트에서의 동작을 자동으로 보장하지 않는다.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| role mapper instance-level `Sync Mode Override=FORCE`가 IdP default `IMPORT`보다 우선하는지 | 적용 계층은 공식 근거가 있으나 배포 버전 realm export/UI 실측 없음 | role mapper만 FORCE로 설정하고 2회 로그인 후 role 갱신과 profile attribute 보존을 함께 확인 | `needs-confirmation` |
|
||||
| Advanced Claim to Role mapper 가 `hd=example.com` 일 때만 `internal-user` role 부여 (운영 SaaS 시) | `KC-IDP-MAPPER-C4` 가 명시적으로 `needs-confirmation` — mapper 종류 verbatim 부재 | Keycloak Admin UI 의 IdP → Mappers → Add mapper → Advanced Claim to Role 캡쳐 + 실제 등록 후 `hd` 별 token 발급 → role 차이 확인 | `planned` |
|
||||
| `email_verified=false` 전체 hard-reject variant | 현재 custom SPI provider/JAR/flow export가 없음 | 별도 SPI branch를 만들 때 provider artifact + realm flow export + negative E2E로 검증 | `deferred` |
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 학습 노트. P2B는 `documented-only` 유지.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: 학습 노트 (`documented-only`)
|
||||
- **wiki 추출 대상**:
|
||||
- `actually-implemented` 항목: (없음)
|
||||
- `locally-verified` 항목: (없음)
|
||||
- `prod-verified` 항목: (없음)
|
||||
- **추출하지 않을 항목**: 전 항목 (`documented-only`)
|
||||
+513
@@ -0,0 +1,513 @@
|
||||
---
|
||||
title: branch / feature-keycloak-internal-spa-direct-google-federation (P2B Internal SPA + Resource Server + Google federation)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-CHILD-028FAA28
|
||||
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-internal-spa-direct-google-federation
|
||||
parent_branch: feature-keycloak-patterns
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, auth, oauth2, oidc]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 378fb324f192fa41f903d9d9158a9ce7318aa645769b57283fdd8de553efd5af
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-internal-spa-direct-google-federation (P2B — Internal SPA + Resource Server + Google federation)
|
||||
|
||||
> Layer: `raw/branch-notes/` — root [[raw/branch-notes/feature-keycloak-patterns]]의 sub-branch.
|
||||
> **P2B** = P2A (내부 배치 SPA + Resource Server, public client, Authorization Code + PKCE) **+ Google IdP brokering**.
|
||||
> 비교축:
|
||||
> - vs **P2A** ([[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]]): Google federation 추가 시 흐름·코드 변화.
|
||||
> - vs **P1B** ([[raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation]]): brokering 구조는 동일, 단 SPA가 token을 직접 보유 (XSS surface 차이).
|
||||
|
||||
> **본 노트의 역할 (2026-07-17 /branch-spec 정리)**: P2B 는 **구성(composition) 허브**다. brokering 의 개별 관심사(First Broker Login Flow · claim/attribute mapping · Google client 등록 · 3-leg trust)는 각각 **owner 브랜치**가 소유하며, 본 노트는 `rules/consistency-contract.md` 의 **Reference-Only** 규약에 따라 포인터 + 1줄 요약으로만 인용한다. 본 노트가 직접 소유하는 결정은 **D5 · D8 · D9 · D10** 이다.
|
||||
|
||||
<!-- 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 변형이다 | SPA Direct와 Google federation의 조합을 AP1 및 brokering 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 -->
|
||||
## 목표
|
||||
|
||||
P2A에 Google federation을 추가했을 때 흐름이 어떻게 바뀌는지 정확히 이해. **핵심 통찰**:
|
||||
|
||||
> **SPA 입장에선 Keycloak만 통신한다.** Google과 직접 통신하지 않는다. Google 통신은 Keycloak 내부(server-to-server)에서만 발생한다. **SPA 코드 변경 거의 0.**
|
||||
|
||||
이게 IdP brokering의 우아함이다. P2A에서 추가되는 건:
|
||||
|
||||
- Keycloak 관리자 설정 (Identity Provider 등록, Mappers 구성, First Broker Login Flow 정책)
|
||||
- Google Cloud Console에서 OAuth Client 등록 (redirect URI = Keycloak의 broker endpoint)
|
||||
|
||||
SPA `keycloak-js` 초기화 코드, 백엔드 Resource Server JWT validation 코드, audience/issuer 확인 로직은 **그대로**.
|
||||
|
||||
면접 질문: "Google 로그인이 붙으면 SPA 코드 어디가 바뀌나요?" → "거의 안 바뀝니다. Keycloak이 Google을 자기 안으로 broker하기 때문에 SPA가 보는 token은 여전히 Keycloak token입니다. 변경 지점은 Keycloak 관리자 설정이고, 운영 부담은 사용자 매핑(First Broker Login Flow)과 claim mapping에 있습니다."
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- **P2B 구성(composition) 자체** — P2A + Google brokering 을 세우는 순서와 각 단계의 owner 브랜치 배선 (§구현 가이드 §1).
|
||||
- **P2A→P2B 변경량(zero-change) 논증** — 어느 레이어가 안 바뀌는지의 검증 지점 (§구현 가이드 §2, D8).
|
||||
- **federation 축 ⟂ token 보유 축의 직교성 논증** — brokering 비용은 P1B/P2B 동일, 차이는 브라우저 token 보유뿐 (D9).
|
||||
- **Keycloak Identity Broker SPI 미사용 결정** — 구성요소 결정 (D5).
|
||||
- **P2B 배포 realm 전제의 선언** — 학습 realm 의 SMTP 미설정 사실과 그로 인한 Verify-Existing-Account 폴백 (D10). owner 가 "배포 realm 사실"로 범위 밖에 둔 결정 변수를 hub 가 소유.
|
||||
- **3-leg trust / claim mapping / First Broker Login 의 *배선과 인용*** — 정책 자체는 owner 브랜치 소유, 본 노트는 조립만.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외. 아래는 모두 **owner 브랜치가 소유** — 본 노트는 결정하지 않고 consume 한다 (Reference-Only).
|
||||
|
||||
- **First Broker Login Flow 의 authenticator 구성·AutoLink 정책** → [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D1 · D2 · D4 소유.
|
||||
- **Google claim → Keycloak attribute 매핑 · Sync Mode** → [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D3 · D4 소유.
|
||||
- **link key (`sub` vs `email`) 및 계정 탈취 시나리오** → [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] D1 소유.
|
||||
- **Google Cloud OAuth client 등록 · scope · `trustEmail` · 환경 분리** → [[raw/branch-notes/feature-keycloak-idp-brokering-google-client]] D2 · D4 · D6 · D7 소유.
|
||||
- **SPA token 저장 위치 (메모리/cookie/localStorage)** → [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] D1 · D2 소유.
|
||||
- **BFF 패턴 실 구현** → [[raw/branch-notes/feature-keycloak-bff-vs-spa-direct]] D2 소유 (`documented-only` 유지).
|
||||
- **`hd` claim → role 매핑 (RBAC)** → [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]] D3 소유.
|
||||
- **P2B 의 실제 코드 구현** — 본 프로젝트는 학습용 문서·다이어그램 단계. 코드 repo 부재 (§Audit & Findings `NO_CODE_REPO`).
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 본 sub-branch의 P2B (Internal SPA + Google federation) 채택 근거. 상세 대안 비교는 §외부 근거 / 대안 조사 참조.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/keycloak-identity-brokering-overview-official]] | Keycloak Identity Broker overview — Google IdP brokering 채택 근거 (D8) |
|
||||
| [[raw/official-docs/keycloak-first-broker-login-flow]] | First Broker Login Flow — 사용자 매핑 시점 결정 근거 (D2, 위임) |
|
||||
| [[raw/official-docs/google-oidc-discovery-spec]] | Google OIDC discovery — Google IdP 표준 동작 근거 |
|
||||
| [[raw/official-docs/keycloak-identity-provider-mappers]] | IdP Mappers — claim mapping 근거 (D6, 위임) |
|
||||
| [[raw/official-docs/keycloak-identity-broker-spi]] | Custom Identity Broker SPI — 대안 (현재 unused, D5) |
|
||||
| [[raw/official-docs/oauth2-browser-based-apps-ietf-draft]] | **(2026-07-17 추가)** IETF BCP — BFF > Token-Mediating > Browser-based client 보안 순서. D9 (P2B 의 token 보유 수용) 의 공식 근거 |
|
||||
| [[raw/official-docs/owasp-html5-storage-xss-spa]] | **(2026-07-17 추가)** 단일 XSS 로 storage 전량 탈취 — D9 의 위협 모델 근거 |
|
||||
| [[raw/official-docs/keycloak-first-broker-login-verify-authenticators-official]] | **(2026-07-17 추가)** AutoLink = 별도 opt-in dangerous authenticator (공식 WARNING). 본 노트 2026-05-25 prose 의 **정정** 근거 |
|
||||
| [[raw/official-docs/keycloak-identity-provider-sync-mode-official]] | **(2026-07-17 추가)** Sync Mode `import`/`force` 정의. D3 위임처의 근거 |
|
||||
| [[raw/official-docs/spring-security-resource-server-jwt]] | Resource Server 의 `issuer-uri` 검증 — D7 (위임) 의 근거 |
|
||||
|
||||
## 외부 근거 / 대안 조사 (2026-05-25 — P2B Internal SPA + Google IdP Brokering)
|
||||
|
||||
본 sub-branch의 **P2A + Google IdP Brokering** 채택에 대한 외부 source. P2A에 federation을 추가하는 방식 비교.
|
||||
|
||||
- **채택 결정 (Keycloak Identity Brokering + Identity Provider Mappers + First Broker Login Flow)**:
|
||||
- [[raw/official-docs/keycloak-identity-brokering-overview-official]] — Keycloak Identity Broker overview
|
||||
- [[raw/official-docs/keycloak-first-broker-login-flow]] — First Broker Login Flow (사용자 매핑 시점)
|
||||
- [[raw/official-docs/google-oidc-discovery-spec]] — Google OIDC discovery (issuer, jwks_uri, claims)
|
||||
- [[raw/official-docs/keycloak-identity-provider-mappers]] — IdP Mappers (Google claim → Keycloak attribute/role)
|
||||
- [[raw/official-docs/keycloak-identity-broker-spi]] — Custom Identity Broker SPI (custom IdP 작성 시)
|
||||
- **검토한 대안**:
|
||||
- **대안 1: Google OIDC 직접 (Keycloak 우회)** — SPA가 Google OIDC `accounts.google.com`에 직접 요청. 장: Keycloak 운영 부담 0 / 단: 다중 IdP(예: GitHub, SAML) 통합 시 SPA 코드 분기 폭증.
|
||||
- **대안 2: AWS Cognito User Pools + Google federation** — AWS Cognito가 broker 역할. 장: managed / 단: vendor lock-in.
|
||||
- **대안 3: Auth0 social connections** — Auth0가 Google + Facebook + GitHub 등 통합. 장: 운영 부담 최소 / 단: 비용 + vendor lock-in.
|
||||
- **대안 4: Firebase Authentication** — Google 자사 IdP managed. 단: Firebase 종속.
|
||||
- **대안 5: SAML federation (Google Workspace SAML)** — 엔터프라이즈 환경. 그러나 일반 사용자 Google 계정에는 OIDC가 표준.
|
||||
- **비교 핵심**: IdP Brokering의 가치는 P1B와 동일하지만, **SPA 컨텍스트에서 특히 중요**: SPA 코드는 Keycloak만 알면 되고, "Sign in with Google" 버튼은 Keycloak 로그인 화면이 제공 → SPA가 IdP 종류를 모름. **Claim mapping**으로 Google의 `email`, `picture`, `name` → Keycloak attribute / role 매핑 가능. **3-leg trust** (Browser ↔ Keycloak ↔ Google) — 각 단계 검증 필요. **First Broker Login Flow의 default Auto-Link은 보안 위험** (P1B와 동일) — Confirm Link Existing Account로 변경.
|
||||
|
||||
> **정정 (2026-07-17)** — 위 "비교 핵심" 마지막 문장(`default Auto-Link은 보안 위험 … Confirm Link Existing Account로 변경`)은 **사실과 다르다**. OOTB 기본 경로는 **이미** `Handle Existing Account`(Confirm Link)이며, `Automatically Set Existing User`(AutoLink)는 **기본값이 아니라 관리자가 별도로 추가하는 opt-in dangerous authenticator**다 — 공식 WARNING: `raw/official-docs/keycloak-first-broker-login-verify-authenticators-official.md#KC-FBLVERIFY-C4`. 따라서 "AutoLink → Confirm Link 로 **변경**"이라는 조치는 불필요하며, 실제 결정은 "AutoLink 를 **추가하지 않는다**"이다.
|
||||
> 권위 있는 서술은 owner 노트 [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D1 — AutoLink 미사용/DISABLED, OOTB 기본은 Confirm Link.
|
||||
> 원문은 학습 이력 보존을 위해 **verbatim 유지**한다(덮어쓰지 않음). 상세는 §Audit & Findings `CONTRADICTION-1`.
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] P2A sub-branch 작성 후 코드 변경량 정량 비교 (실 LoC diff) — 등급: `planned`
|
||||
- [ ] Keycloak admin UI에서 Google IdP 등록 스크린샷 캡처 (학습 자료) — 등급: `planned`
|
||||
- [ ] First Broker Login Flow custom (비밀번호 확인 후 link) authenticator 설정 절차 정리 — 등급: `planned`
|
||||
- [ ] XSS 시 token 탈취 시나리오 정리 (P1B와의 본질적 차이) — 등급: `planned`
|
||||
- [ ] Mapper 세부 종류표 공식 문서 재확인 (`needs-confirmation` 해소) — 등급: `planned`
|
||||
- [x] **(2026-07-18 정합)** attribute default `IMPORT`와 role mapper override `FORCE`의 적용 계층을 owner 문서에서 분리하고 본 hub는 포인터만 유지 — 등급: `documented-only`
|
||||
- [ ] **(2026-07-17 추가, 우선)** `CVE-2026-9087` 공식 advisory 를 `wiki-source-summarizer` 로 `raw/official-docs/` 에 아카이브 — 현재 sibling 노트 2곳의 자기 보고만 있어 §구현 가이드 §1 0단계가 `needs-confirmation` 게이트에 머묾. 아카이브 후 영향/patch 버전을 FACT 로 승격 — 등급: `needs-confirmation`
|
||||
- [ ] **(2026-07-17 추가)** P1B ↔ IETF draft BFF 정의 매핑 검증 (D9 의 "P2B < P1B" 를 표준 권위로 말할 수 있는지) — 등급: `planned`
|
||||
- [ ] **(2026-07-17 추가)** **CSRF / `state` 방어의 owner 지정** — draft §6.3.2 가 browser-based client 에 **CSRF 방어 MUST** 를 요구하고 근거 raw 가 "P2 sub-branch 에서 별도 검증"으로 지목하는데, P2A·P2B 어느 노트도 Decision 으로 소유하지 않음(P2A 는 미체크 체크리스트 항목뿐). `#OAUTH-BBA-C3` 의 허용이 *조건부*이므로 D9 의 전제이기도 함 — 등급: `planned`
|
||||
- [x] **(2026-07-18 정합)** P2A D4는 본 노트 D8 pointer로 전환해 zero-change owner를 본 hub 하나로 고정 — 등급: `documented-only`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- **2026-07-17 (`/branch-spec` 채움 pass)** — 본 노트는 2026-05-25 작성분으로, 이후 2026-07-14~16 에 brokering 의 개별 관심사를 다루는 owner 브랜치들이 대거 작성되며 **본 노트의 결정 대부분이 owner 를 획득**했다. 따라서 이번 pass 의 핵심 작업은 *새 결정을 추가*하는 것이 아니라 **재진술(restatement)을 Reference-Only 포인터로 강등**하는 것이었다 (`rules/consistency-contract.md` Single-Owner).
|
||||
- **자동조사(`wiki-decision-researcher`) 미실행** — 본 노트의 `needs-confirmation` 결정들이 기다리던 근거가 **이미 raw 에 아카이브되어 있었다**(2026-07-14~16 수집분: `oauth2-browser-based-apps-ietf-draft` · `keycloak-identity-provider-sync-mode-official` · `keycloak-first-broker-login-verify-authenticators-official` · `keycloak-identity-provider-trust-email-official`). 새 조사 대신 **기존 아카이브 재앵커링**으로 해소 — 조사 0건, deferred 0건.
|
||||
- **D9 가 이번 pass 의 최대 수확** — `OAUTH-BBA-C4` (IETF BCP 가 BFF → Token-Mediating → Browser-based Client 를 *보안 감소 순서*로 명시) 는 그동안 "XSS 위험이 높다"는 정성적 서술에 머물던 P2B vs P1B 비교에 **official-standard 등급의 순서 근거**를 부여한다. 2026-05-25 시점엔 이 source 가 없었다.
|
||||
- 코드 repo 가 없으므로 §구현 가이드는 *코드 명세*가 아니라 **admin 구성 절차 + owner 브랜치 배선 순서**로 작성했다. 모든 항목 등급은 `documented-only` 또는 `planned`.
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
> 외래 결정은 Reference-Only로 유지한다. 과거 상세 결정문은 각 owner의 history에서 추적한다.
|
||||
|
||||
- [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D4 — 현재 정책은 `email_verified=false` 전체 hard-reject가 아니라 **silent auto-link 차단**이다. → D1
|
||||
- [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D2 — 기존 계정 link는 Confirm Link 소유증명을 거친다. → D2
|
||||
- [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D3 — IdP-level profile attribute default는 `IMPORT`다. [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]] D1의 `FORCE`는 role mapper override다. → D3
|
||||
- [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]] D3 — 학습 단계에서 `hd` role 제한을 적용하지 않는다. → D4
|
||||
- 2026-05-25: **Keycloak SPI 미사용.** Google built-in social provider + Mappers로 충분. 커스텀 IdP가 필요할 때 SPI 검토 ([[raw/official-docs/keycloak-identity-broker-spi]]).
|
||||
- → **(2026-07-17) 본 노트 OWNED 유지** — 다른 어떤 브랜치도 SPI 채택 여부를 소유하지 않음. → D5
|
||||
- **2026-07-17: P2B 의 federation 은 Keycloak 레이어에서만 broker 한다** (SPA 는 Google 과 직접 통신하지 않음). 이유: 다중 IdP 확장 시 SPA 코드 분기 폭증을 차단 — brokering 의 본질적 가치. 검토한 대안: §외부 근거 대안 1~5. → D8
|
||||
- **2026-07-17: P2B 는 브라우저가 token 을 보유하는 구조를 *의식적으로 수용*한다** (P1B/BFF 대비 공식 보안 순서상 하위). 이유: 학습 목표가 canonical OIDC+PKCE 흐름 관찰이며, federation 축과 token 보유 축은 **직교**하기 때문. 근거: `#OAUTH-BBA-C4`. → D9
|
||||
|
||||
## 컴포넌트 다이어그램
|
||||
|
||||
```
|
||||
Browser (SPA, public client)
|
||||
│
|
||||
│ (1) GET /index.html
|
||||
│ (2) keycloak-js → Authorization Code + PKCE 시작
|
||||
▼
|
||||
Keycloak (Authorization Server)
|
||||
│ ┌─ 사용자가 "Google 로그인" 버튼 클릭 ─┐
|
||||
│ │ (3a) Keycloak이 /broker/google/login으로 redirect
|
||||
│ ▼ │
|
||||
│ Google OIDC (accounts.google.com) │
|
||||
│ │ (3b) Google 로그인 + consent │
|
||||
│ │ (3c) authorization code → Keycloak broker endpoint
|
||||
│ ▼ │
|
||||
│ Keycloak ↔ Google (server-to-server)│
|
||||
│ - token endpoint POST │
|
||||
│ - ID token signature 검증 (JWKS)│
|
||||
│ - email_verified, hd 정책 검사 │
|
||||
│ - First Broker Login Flow: │
|
||||
│ - 기존 user 찾기 / 신규 생성 │
|
||||
│ - Account Linking 결정 │
|
||||
│ - IdP Mappers 적용 (claim → attribute / role)
|
||||
│ └─────────────────────────────────┘
|
||||
│
|
||||
│ (4) Keycloak이 자체 Authorization Code 발급 → SPA로 redirect
|
||||
▼
|
||||
Browser (SPA)
|
||||
│ (5) code → POST /token (PKCE verifier 동봉)
|
||||
│ (6) Keycloak access_token(JWT) + refresh_token + id_token 수신
|
||||
▼
|
||||
Backend (Resource Server, Spring Boot 등)
|
||||
│ (7) Authorization: Bearer <Keycloak access_token>
|
||||
│ (8) JWKS로 signature 검증 + iss/aud/exp 확인
|
||||
▼
|
||||
200 OK
|
||||
```
|
||||
|
||||
**핵심**: 백엔드는 **Keycloak token만** 본다. Google ID token은 Keycloak 안에서 검증되고 폐기된다 (필요 시 broker endpoint로 회수 가능, 그러나 본 패턴에선 사용 안 함).
|
||||
|
||||
## 토큰 교환 sequence (P2A 1-7 + brokering 삽입)
|
||||
|
||||
| # | From | To | Payload | 비고 |
|
||||
|---|------|----|---------| ---- |
|
||||
| 1 | Browser | Keycloak `/realms/{r}/protocol/openid-connect/auth` | client_id, redirect_uri, code_challenge, scope=openid email profile, state | P2A와 동일 |
|
||||
| 2 | Keycloak | Browser | 로그인 페이지 (HTML) — "Sign in with Google" 버튼 포함 | IdP 등록 시 자동 노출 |
|
||||
| **3a** | Browser | Keycloak `/realms/{r}/broker/google/login` | (사용자가 Google 버튼 클릭) | **P2B 추가** |
|
||||
| **3b** | Keycloak | Browser | 302 → `https://accounts.google.com/o/oauth2/v2/auth?...` (Keycloak이 Google client_id, redirect_uri=Keycloak broker endpoint, scope, state, nonce 동봉) | **P2B 추가** |
|
||||
| **3c** | Browser | Google | 로그인 + consent | **P2B 추가** |
|
||||
| **3d** | Google | Browser | 302 → Keycloak `/realms/{r}/broker/google/endpoint?code=...` | **P2B 추가** |
|
||||
| **3e** | Keycloak | Google `/token` | code, client_id, client_secret (server-to-server) | **P2B 추가** |
|
||||
| **3f** | Google | Keycloak | Google access_token + id_token (RS256) | **P2B 추가** |
|
||||
| **3g** | Keycloak | Google JWKS | (캐시된 키로) ID token signature 검증 | **P2B 추가** |
|
||||
| **3h** | Keycloak | (internal) | First Broker Login Flow 실행 → user 매핑/생성 → Mappers 적용 | **P2B 추가** |
|
||||
| 4 | Keycloak | Browser | 302 → SPA redirect_uri + Keycloak `code` | P2A와 동일 |
|
||||
| 5 | Browser (SPA) | Keycloak `/token` | grant_type=authorization_code, code, code_verifier (PKCE) | P2A와 동일 |
|
||||
| 6 | Keycloak | Browser | Keycloak access_token (JWT, RS256) + refresh_token + id_token | P2A와 동일 |
|
||||
| 7 | Browser | Backend `/api/...` | Authorization: Bearer <access_token> | P2A와 동일 |
|
||||
| 8 | Backend | Keycloak JWKS | (캐시) | P2A와 동일 |
|
||||
|
||||
**P1B와의 차이**: P1B는 oauth2-proxy가 token을 보유 (브라우저에 cookie). P2B는 브라우저가 직접 token 보유. brokering 부분(3a-3h)은 둘이 동일.
|
||||
|
||||
## 장점 / 단점
|
||||
|
||||
### vs P2A (no Google)
|
||||
|
||||
| 항목 | P2A | P2B |
|
||||
|------|-----|-----|
|
||||
| Google 계정으로 로그인 | ✗ | ✓ |
|
||||
| SPA 코드 변경 | — | **거의 0** (button label 정도) |
|
||||
| 백엔드 코드 변경 | — | **0** (여전히 Keycloak JWT만 검증) |
|
||||
| 운영 부담 | Keycloak realm/client만 | Keycloak realm/client + Google Cloud OAuth + IdP Mappers + First Broker Login Flow 정책 |
|
||||
| 사용자 매핑 정책 | 불필요 (Keycloak 자체 가입) | 필수 (Account Linking, email_verified 정책 등) |
|
||||
| 신뢰 경계 | Browser ↔ Keycloak (2-leg) | Browser ↔ Keycloak ↔ Google (3-leg) |
|
||||
| 토큰 revocation | Keycloak refresh token revoke | 동일 (Google revoke는 별개) |
|
||||
|
||||
### vs P1B (Edge proxy + Google)
|
||||
|
||||
| 항목 | P1B (oauth2-proxy 패턴) | P2B (SPA Direct) |
|
||||
|------|-----|-----|
|
||||
| 브라우저의 token 보유 | ✗ (cookie session만) | ✓ (sessionStorage/메모리) |
|
||||
| XSS risk | 낮음 (token이 브라우저 JS 접근 밖) | **높음** (XSS 시 token 탈취 가능) |
|
||||
| backend 추가 | proxy 필요 | proxy 불필요 |
|
||||
| SPA가 OIDC 처리 | 모름 (proxy가 처리) | 직접 처리 (keycloak-js 등) |
|
||||
| brokering 흐름 | 동일 | 동일 |
|
||||
| token revocation | proxy session 무효화 (즉시) | Keycloak refresh token revoke (access token은 만료까지 유효) |
|
||||
|
||||
**요약**: brokering 추가의 운영 비용은 P1B/P2B 모두 같다. 두 패턴 차이는 "브라우저가 token을 보느냐"이며 이는 federation과 직교한다.
|
||||
|
||||
> **(2026-07-17 근거 보강)** 위 요약의 "직교" 논증은 D9 로 승격됐고, 이제 **부분적으로 공식 근거**를 가진다. IETF `oauth2-browser-based-apps` draft 는 세 패턴을 **보안 감소 순서**로 제시한다 — BFF → Token-Mediating Backend → Browser-based OAuth 2.0 Client (`#OAUTH-BBA-C4`, verbatim: "presented in decreasing order of security").
|
||||
>
|
||||
> **표준이 확정한 것 vs 본 노트가 매핑한 것을 분리한다** — 아래 ②·③에 ①의 권위를 빌려주면 안 된다:
|
||||
>
|
||||
> 1. **표준이 확정 (추상 수준)**: 세 패턴의 **보안 감소 순서** 자체 (`#OAUTH-BBA-C4`). draft 는 *추상 3분류*를 정의하고 순서를 매길 뿐, **우리 프로젝트의 P1A~P3B 6조합을 이 분류에 배정해주지 않는다**.
|
||||
> 2. **본 노트의 매핑 판정 — 강함**: **P2B ∈ Browser-based OAuth 2.0 Client** (`#OAUTH-BBA-C3`: 브라우저 앱이 public client 로 모든 OAuth 책임을 지고 token 을 직접 보유 → P2B 서술과 축자 일치). 근거는 견고하나 **표준이 확정해준 것은 아니다**.
|
||||
> 3. **본 노트의 매핑 판정 — 약함**: **P1B(oauth2-proxy / Traefik ForwardAuth) ∈ BFF 계열**. 근거 raw 가 이 매핑을 **특정해 부인**한다 — `raw/official-docs/oauth2-browser-based-apps-ietf-draft.md` "Does not prove": *P1A/P1B/P2A/**P2B**/P3A/P3B 6개 구체적 조합이 이 3분류와 **1:1로 정확히 대응한다는 것** — 특히 P1(oauth2-proxy/ForwardAuth)이 이 draft 의 "BFF" 정의와 정확히 같은 개념인지는 별도 확인 필요*.
|
||||
>
|
||||
> **따라서 "P2B < P1B" 는 표준 권위로 단정할 수 없다** — 그 단정은 ②와 ③ **둘 다** 참이어야 성립하며, raw 는 ②·③ 모두를 1:1 대응 부인 목록에 넣었다. 두 매핑 각각을 §Claims To Verify 로 분리 검증한다.
|
||||
>
|
||||
> **그래도 D9 의 착수 판단은 무너지지 않는다**: D9 가 실제로 요구하는 것은 "P2B 는 브라우저에 token 을 두므로 그만큼 노출을 수용한다"이고, 이는 ②(축자 일치)만으로 성립한다. P1B 와의 *상대 순서*는 D9 의 대안 분기("XSS 가 유의하면 P1B 로 이동")에만 필요하며 그 분기는 미검증 상태다. 또한 이 순서는 Google federation 유무를 변수로 다루지 않으므로 **"직교" 주장 역시 본 노트의 추론**이며 §Claims To Verify 대상이다.
|
||||
|
||||
## claim mapping (Google → Keycloak)
|
||||
|
||||
- [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D3/D4 — profile attribute mapping과 IdP-level default `IMPORT`의 정본이다.
|
||||
- [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]] D1/D3 — role mapper override와 `hd` 기반 RBAC의 정본이다.
|
||||
|
||||
P2B composition 관점의 한 줄 불변식: Google claim은 Keycloak 내부 user/role로 정규화된 뒤, SPA와 backend에는 Keycloak이 발급한 token만 노출된다.
|
||||
|
||||
## 신뢰 경계 (3-leg trust)
|
||||
|
||||
- [[raw/branch-notes/feature-keycloak-three-leg-trust-chain]] D5 — hop별 검증 matrix의 정본이다.
|
||||
- [[raw/branch-notes/feature-keycloak-three-leg-trust-chain]] D1 — backend는 Google token을 직접 수용하지 않고 Keycloak token/JWKS만 신뢰한다.
|
||||
|
||||
P2B composition 관점의 한 줄 불변식: Google↔Keycloak 검증과 Keycloak↔Backend 검증은 서로 다른 hop이며, backend의 trust anchor는 Keycloak이다.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> P2B 는 **구성 허브**다. 아래 D1~D4 · D6 · D7 은 **DELEGATED** — owner 브랜치가 결정을 소유하고 본 노트는 Reference-Only 포인터 + 1줄 요약만 보유한다 (`rules/consistency-contract.md`). 본 노트가 **직접 소유**하는 결정은 **D5 · D8 · D9** 뿐이다.
|
||||
> Decision ID 는 2026-05-25 판과 동일하게 유지한다(외부 참조 안정성). 위임된 행도 ID 를 재사용 번호로 남긴다.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | **DELEGATED** — [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D4 — silent auto-link 차단 | owner 참조 | owner 참조 | `delegated` | hard-reject SPI variant는 현재 미구현 |
|
||||
| D2 | **DELEGATED** — [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D2 — 기존 계정 link에 소유 증명 적용 | 본 realm의 SMTP 분기는 아래 D10이 조립 | owner 참조 | `delegated` | owner의 lockout risk 승계 |
|
||||
| D3 | **DELEGATED** — [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D3 — IdP-level profile default `IMPORT` | role freshness는 [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]] D1의 mapper override | owner 참조 | `delegated` | runtime override 우선순위는 `needs-confirmation` |
|
||||
| D4 | **DELEGATED** — [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]] D3 — 학습 단계 `hd` 제한 미적용 | owner 참조 | owner 참조 | `delegated` | personal account 처리는 owner 검증 대상 |
|
||||
| **D5** | **OWNED** — Keycloak Identity Broker SPI 미사용. Google built-in social provider + Mappers 로 충분 | **built-in social provider 가 대상 IdP 를 지원**(Google=지원)하면 SPI 미사용. 대안(SPI 작성): 프로토콜이 OIDC/SAML 이 아니거나, built-in 이 제공 못 하는 비표준 claim 처리·custom 인증 단계가 필요할 때 | `raw/official-docs/keycloak-identity-brokering-overview-official.md#KC-IDP-BROKER-C1`, `raw/official-docs/keycloak-identity-broker-spi.md#KC-BROKER-SPI-C1`, `#KC-BROKER-SPI-C2` | `official-vendor-doc` | SPI 검토 trigger 의 정량 기준 부재 — "built-in 이 부족한 시점"이 학습 단계 가정에 의존 (Should-fix, 운영 전환 시 재평가) |
|
||||
| D6 | **DELEGATED** — [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D4 — profile attribute mapping | owner 참조 | owner 참조 | `delegated` | link key는 [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] D1 |
|
||||
| D7 | **DELEGATED** — [[raw/branch-notes/feature-keycloak-three-leg-trust-chain]] D1 — backend trust anchor는 Keycloak | owner 참조 | owner 참조 | `delegated` | owner의 미검증 항목 승계 |
|
||||
| **D8** | **OWNED** — P2B 의 federation 은 **Keycloak 레이어에서만** broker (SPA 는 Google 과 직접 통신하지 않음). *메커니즘*: IdP 를 realm 에 등록하면 Keycloak **로그인 페이지가 버튼을 자동 제공**하므로 SPA 코드가 IdP 를 몰라도 됨 | **IdP 가 1개 초과로 늘어날 가능성**이 있거나 **IdP 종류를 앱에서 숨기고 싶으면** brokering. 대안: IdP 가 영구히 Google 1개 + Keycloak 운영 부담을 피하고 싶다 → SPA 가 Google OIDC 직접(§외부 근거 대안 1). managed 선호 → Cognito/Auth0/Firebase(대안 2~4). **경계**: `Hide on Login Page`=ON 이면 버튼이 안 뜨고 앱이 `kc_idp_hint` 를 보내야 함 → 그 순간 zero-change 전제가 깨지고 자식 [[raw/branch-notes/feature-keycloak-federation-spa-zero-change]] D1 (idpHint 미사용) 의 대안 경로로 이동 | `raw/official-docs/keycloak-identity-brokering-overview-official.md#KC-IDP-BROKER-C1` (broker delegation 모델, L0/L1); **(2026-07-17 보강 — L1/L2)** `raw/official-docs/keycloak-idp-hide-on-login-page-toggle-official.md#KC-HIDELOGIN-C2` (IdP 구성 시 로그인 페이지에 **로그인 옵션으로 나타남** = zero-change 의 메커니즘, verbatim), `#KC-HIDELOGIN-C1` (realm 의 등록된 **모든** IdP 를 앱이 사용 가능·기본 활성 → 앱별 코드 불요), `#KC-HIDELOGIN-C3` (**ON 일 때만** 미노출 + `kc_idp_hint` 대안 = 조건·경계 L2), `raw/official-docs/keycloak-google-idp-setup.md#KC-GIDP-C1` (Google 등록 경로) | `official-vendor-doc` (L1~L2) | ① "IdP 가 늘어난다"는 전제가 학습 프로젝트에선 가정 — 실제 다중 IdP 요구 미발생 시 대안 1 이 더 단순 (Advisory). ② **`Hide on Login Page` 토글의 *신규 등록 시 기본 상태*는 `KC-HIDELOGIN-C3` 원문이 직접 진술하지 않음** → 기본이 ON 이면 zero-change 전제 붕괴. 자식 노트가 동일 caveat 를 `needs-confirmation` 으로 보유 → visual verify 필수 (§Claims To Verify) |
|
||||
| **D10** | **OWNED (2026-07-17 신규 — hub 가 소유하는 *배포 realm 사실*)** — P2B 학습 realm 은 **SMTP 를 설정하지 않는다** → `Verify Existing Account By Email` 을 쓸 수 없어 **Re-authentication 이 자동 폴백**됨 | **학습 스택(메일 서버 없음)** → SMTP 미설정 → owner 의 fork 중 "Re-authentication" 가지가 *자동으로* 선택됨(관리자 조치 불요). 대안: 운영/소비자 서비스로 전환해 SMTP 를 붙이면 → `Verify Existing Account By Email` 이 기본(`ALTERNATIVE`)이 되므로, password 소유 증명을 관철하려면 그때 **email authenticator 를 명시 DISABLE** 해야 함 → [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D2 | `raw/official-docs/keycloak-first-broker-login-verify-authenticators-official.md#KC-FBLVERIFY-C3` (Re-auth = "email authenticator 를 쓸 수 없을 때(예: realm 에 SMTP 미설정)만 쓰는" **폴백**, verbatim), `#KC-FBLVERIFY-C1` (Email = SMTP 시 기본) | `official-vendor-doc` + `documented-only` (배포 사실) | **SMTP 미설정은 *부재 근거***: [[raw/branch-notes/feature-keycloak-docker-compose-stack]] 이 SMTP/mail 을 일절 구성하지 않음에서 도출했을 뿐, 스택 노트가 "SMTP 없음"을 *명시 선언*하지는 않는다 → 실 스택 기동 시 realm SMTP 설정란 확인 필요 (§Claims To Verify). owner 는 이 realm 사실을 **자기 범위 밖(`UNSUPPORTED_IMPL_DECISION`)으로 명시 배제**했으므로 hub 인 본 노트가 소유한다 |
|
||||
| **D9** | **OWNED** — P2B 는 브라우저 token 보유를 **의식적으로 수용**. federation 축과 token 보유 축은 **직교** — brokering 비용은 P1B/P2B 동일 | **학습 목표가 canonical OIDC+PKCE 흐름 관찰**이면 P2B 수용. 대안: XSS 위협이 유의(운영·민감 데이터) → P1B/BFF 계열로 이동(토큰을 브라우저 밖으로). 저장 위치 선택은 [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] D1 | `raw/official-docs/oauth2-browser-based-apps-ietf-draft.md#OAUTH-BBA-C4` (BFF→TMB→Browser-based 는 **보안 감소 순서**, verbatim), `#OAUTH-BBA-C3` (browser-based client 정의), `raw/official-docs/owasp-html5-storage-xss-spa.md#OWASP-HTML5-C2` (단일 XSS 로 전량 탈취) | `official-standard + official-reference` | BFF 채택 여부는 [[raw/branch-notes/feature-keycloak-bff-vs-spa-direct]] D1 이 소유 — 본 결정은 그 판정을 P2B(federation 포함)로 확장 적용한 것이며, 확장의 타당성(federation 이 위협 모델을 바꾸지 않음)은 §Claims To Verify 대상 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> **본 브랜치는 코드 repo 가 없다** (학습용 문서·다이어그램 단계 — §Audit `NO_CODE_REPO`). 따라서 본 §는 *코드 명세*가 아니라 **① P2B 를 세우는 구성 순서(owner 브랜치 배선)** 와 **② P2A 대비 zero-change 검증 지점** 을 명세한다. 모든 항목 등급은 `documented-only` 또는 `planned` — `actually-implemented` 주장 없음.
|
||||
> Out-of-scope 정제(R3): 개별 정책 detail(authenticator 토글·mapper 필드·Google client 등록 절차)은 **owner 브랜치 소유이므로 본 §에 재진술하지 않는다** — 포인터만 둔다.
|
||||
|
||||
### 1. P2B 구성 순서 (owner 브랜치 배선)
|
||||
|
||||
> **Trace**: D8 (Keycloak 레이어 brokering) 의 도출. 각 단계의 *정책*은 괄호 안 owner 브랜치가 소유하며 본 표는 **순서와 의존만** 명세한다.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: **단계 순서 자체**(1→5)는 어느 공식 문서도 규정하지 않는다. 사용자 trade-off — Google client 를 먼저 만들어야 Keycloak 에 넣을 client_id/secret 이 생기고(2→3), IdP 를 등록해야 redirect URI 확정값이 나오는 **순환 의존**이 있어, "Keycloak 에서 IdP 를 먼저 생성해 redirect URI 를 얻고 → Google 에 등록 → 되돌아와 secret 입력" 순으로 끊었다. 반대 순서도 가능하나 redirect URI 를 손으로 추측해야 해서 오타 위험이 크다.
|
||||
> - **UNSUPPORTED_DECISION (0단계의 근거 등급)**: `CVE-2026-9087` 은 **raw 에 아카이브된 출처가 없다** — 현재 근거는 sibling branch-note 2곳의 자기 보고뿐이며, CLAUDE.md §11 상 note→note 전이는 근거가 아니다. 따라서 0단계는 "**확인하라**"는 게이트일 뿐 **버전 번호를 FACT 로 단정하지 않는다**(형제 노트가 적은 `26.3.0~26.6.1` / patched `2026-06-02 PR #49513` 도 미검증 전언). 사용자 trade-off — 근거가 얇아도 *계정 탈취 방어의 우회* 라는 주장의 파급이 크므로, 검증 전까지 게이트를 **열어두지 않고 닫아둔다**(fail-safe). 해소: 공식 advisory 를 `wiki-source-summarizer` 로 아카이브 (§TODO).
|
||||
|
||||
| # | 단계 | 산출물 (다음 단계 입력) | 정책 owner (Reference-Only) | 등급 |
|
||||
|---|---|---|---|---|
|
||||
| **0** | **Keycloak 버전 하한 확인** — 본 노트가 consume 하는 D2 의 core 방어(Confirm Link + Verify Existing Account)가 우회되지 않는 patched 버전인지 먼저 확인. 미확인 상태로 1단계 진행 금지 | 버전이 고정된 스택 | 버전 caveat 의 출처는 [[raw/branch-notes/feature-keycloak-idp-brokering-google-client]] D6 의 Open Risk (`CVE-2026-9087` — cross-session verification proof 가 upstream identity 에 미결속, `trustEmail=false`+email 인증 상태에서도 우회 존재로 보고). 형제 [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]] 도 동일 항목을 flag | `needs-confirmation` ⚠️ |
|
||||
| 1 | Keycloak realm 에 Google IdP 생성 → **Redirect URI 확정값 확보** | `https://<kc-host>/realms/<realm>/broker/google/endpoint` | [[raw/branch-notes/feature-keycloak-idp-brokering-google-client]] D1 — Admin Console `Identity Providers` → `Add provider` → `Google` | `planned` |
|
||||
| 2 | Google Cloud Console 에 OAuth Web application client 생성 + 1 의 Redirect URI 등록 | `client_id`, `client_secret` | 같은 노트 D2 (Web application client) · D3 (Redirect URI 그대로 복사, exact-match) | `planned` |
|
||||
| 3 | Keycloak IdP 에 `client_id`/`client_secret` 입력 + scope·discovery·trustEmail 설정 | 동작하는 broker | 같은 노트 D4 (scope `openid profile email` 최소) · D5 (discoveryURL) · D6 (`trustEmail=false`) | `planned` |
|
||||
| 4 | First Broker Login Flow 정책 확정 (AutoLink 추가하지 **않음**) | 사용자 매핑 정책 | [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D1 · D2 · D3 | `planned` |
|
||||
| 5 | IdP Mapper 구성 (attribute importer + Sync Mode) | Keycloak user attribute | [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D3 (IMPORT) · D4 (4종 mapper) | `planned` |
|
||||
|
||||
**검증 종료 조건**: SPA 로그인 화면에 "Sign in with Google" 버튼이 자동 노출되고(3h 이후), SPA 가 받은 token 의 `iss` 가 Keycloak realm URL 이면 P2B 성립.
|
||||
|
||||
### 2. P2A → P2B 변경량 (zero-change 검증 지점)
|
||||
|
||||
> **Trace**: D8 · D9 의 도출 + §목표/WHY 의 핵심 통찰("SPA 코드 변경 거의 0")을 *검증 가능한 형태*로 고정. 자식 [[raw/branch-notes/feature-keycloak-federation-spa-zero-change]] D1 (idpHint 미사용) 이 SPA 측 결정을 소유.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: **"거의 0"의 정량 기준**(LoC diff = 0 인지, button label 1줄인지)은 어떤 근거 문서도 규정하지 않는다. 사용자 trade-off — P2A 실 구현 부재로 diff 를 아직 못 잰다. §TODO 의 "실 LoC diff" 항목으로 이연하며, 그 전까지 "거의 0"은 **주장이지 측정값이 아니다**.
|
||||
|
||||
| 레이어 | P2A | P2B | 기대 변경량 | 검증 방법 |
|
||||
|---|---|---|---|---|
|
||||
| SPA `keycloak-js` 초기화 | `init({onLoad, pkceMethod:'S256'})` | **동일** | **0 줄** | P2A/P2B 설정 파일 diff — 차이 0 이어야 함 |
|
||||
| SPA 로그인 트리거 | `keycloak.login()` | **동일** (idpHint 미사용 → Keycloak 화면이 Google 버튼 노출) | **0 줄** | [[raw/branch-notes/feature-keycloak-federation-spa-zero-change]] D1 |
|
||||
| Backend JWT validation | `issuer-uri` = Keycloak realm | **동일** | **0 줄** | `#SSRS-JWT-C1` — issuer-uri 자동 검증 |
|
||||
| Backend audience 검증 | `aud` = backend client | **동일** | **0 줄** | D7 위임 |
|
||||
| Keycloak admin 설정 | realm + client | **+ IdP + Mappers + FBL flow** | **변경 전량이 여기 집중** | §1 표 |
|
||||
| Google Cloud Console | 없음 | **+ OAuth client** | 신규 | §1 표 2단계 |
|
||||
|
||||
**논증**: 변경량이 전부 마지막 2행(admin/console)에 몰리고 코드 4행이 0 이면, "brokering 은 SPA 에 투명하다"는 D8 의 주장이 성립한다.
|
||||
|
||||
### 3. D5 (SPI 미사용) 의 구현 귀결
|
||||
|
||||
> **Trace**: D5 (OWNED) 의 도출. `#KC-BROKER-SPI-C1`·`#KC-BROKER-SPI-C2` 는 SPI 의 *존재와 용도*를 규정할 뿐, 미사용 시 무엇을 하지 않아도 되는지는 규정하지 않으므로 아래는 그 대우(contrapositive).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음 — 아래는 "SPI 를 안 쓴다"의 직접 귀결이며 새 메커니즘 선택이 아니다.
|
||||
|
||||
| 항목 | SPI 미사용 시 | SPI 채택 시 (대안) |
|
||||
|---|---|---|
|
||||
| 배포 산출물 | Keycloak 컨테이너 이미지 그대로 (jar 추가 없음) | provider jar 빌드 + `providers/` 배치 + 재빌드 |
|
||||
| Keycloak 업그레이드 | built-in provider 가 함께 유지보수됨 | SPI 인터페이스 호환성 직접 추적 |
|
||||
| 구성 방법 | Admin Console 선언적 설정 (§1) | Java 코드 + 컴파일 |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
### 실패·엣지 경로
|
||||
|
||||
| 경로 | 기대 동작 | 소유/근거 |
|
||||
|---|---|---|
|
||||
| 사용자가 Google consent 거부 | Google 이 `error=access_denied` 로 broker endpoint 회신 → Keycloak 로그인 화면 복귀 (SPA 는 code 를 못 받음). **SPA 는 이 실패를 Keycloak 실패와 구분 못 함** — brokering 투명성의 대가 | D8 의 귀결. 정확한 Keycloak 화면 동작은 `needs-confirmation` (§Claims To Verify) |
|
||||
| Google `email_verified=false` 계정 | AutoLink 없이 Confirm Link 소유증명 경로로 처리해 **silent auto-link만 차단**. 전체 link/생성 hard-reject는 현재 미구현 | [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D4 |
|
||||
| 같은 email 의 기존 local user 존재 | Confirm Link 경로 (SMTP realm 은 email 검증이 기본) — **본 노트 원문의 "비밀번호 확인" 은 기본 아님** | [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D2 |
|
||||
| Google-first 가입자(비밀번호 미설정)가 Re-authentication 요구받음 | **lockout 가능** — 재인증 수단 없음 | owner Open Risk 승계 (같은 노트 D2) |
|
||||
| personal Gmail (`hd` claim 부재) | role 미부여 상태 통과 — 학습 단계에선 허용 | [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]] D3 |
|
||||
| Google client_secret 유출 | Google client 재생성 + secret rotation. 환경별 client 분리로 폭발반경 축소 | [[raw/branch-notes/feature-keycloak-idp-brokering-google-client]] D7 |
|
||||
| Google JWKS 키 rotation | Keycloak 이 캐시 갱신 (Keycloak 책임, backend 무관) | [[raw/branch-notes/feature-keycloak-three-leg-trust-chain]] D2 (`UNSUPPORTED_DECISION` 상태) |
|
||||
| **Keycloak 버전이 patch 이전** | D2 가 위임한 core 방어(Confirm Link + Verify Existing Account)가 **우회 가능** — 즉 본 노트가 "owner 가 막아준다"고 가정한 계정 탈취 경로가 실제로는 열려 있을 수 있음. 기대 동작: §구현 가이드 §1 **0단계**에서 차단(스택 기동 전) | [[raw/branch-notes/feature-keycloak-idp-brokering-google-client]] D6 Open Risk (`CVE-2026-9087`). **근거 등급 `needs-confirmation`** — raw 출처 부재, note→note 전언 (§구현 가이드 §1 `UNSUPPORTED_DECISION`) |
|
||||
| **SPA XSS** | access token 탈취 → 만료까지 유효 (즉시 revoke 불가). **P2B 의 본질적 약점** | D9 · `#OWASP-HTML5-C2` |
|
||||
| Keycloak 다운 | Google 로그인 포함 **전 인증 경로 중단** — brokering 은 Keycloak 을 단일 장애점으로 만든다 (P2A 대비 축소 없음, 단 Google 의존이 추가돼도 Keycloak 없이는 무의미) | D8 의 귀결 (Advisory) |
|
||||
|
||||
### 다른 계약 의존
|
||||
|
||||
> 본 노트는 **구성 허브**이므로 의존이 많다. 각 owner 의 D-row 가 바뀌면 본 노트 §DEM 의 1줄 요약이 낡는다 → `/sync` 수거 대상.
|
||||
|
||||
- **[[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]] (P2A) D1 · D3 · D4 · D5 — 본 노트의 *정의상 baseline*** (P2B ≡ P2A + brokering). §구현 가이드 §2 의 "P2A" 열 전량이 P2A 소유 결정이다: D5(PKCE S256 의무) = 1행 baseline, D3(backend = `iss`+signature+`exp`+`aud` 4종) = 3~4행 baseline, D1(P2A = SPA Direct 정의). **바뀌면**: §2 의 zero-change 논증이 *기준선째* 바뀌고 D8·D9 의 전제가 흔들린다.
|
||||
- **2026-07-18 owner 정합 완료**: zero-change invariant는 본 노트 D8이 소유하고 P2A D4는 이 행을 가리키는 Reference-Only pointer로 전환했다.
|
||||
- ⚠️ **미소유 관심사 (P2 sub-branch 지시)**: 인용 raw 가 *본 노트류를 명시 지목*한다 — `raw/official-docs/oauth2-browser-based-apps-ietf-draft.md`: *P2(SPA-direct) 가 이 draft 의 §6.3.2 (**PKCE MUST, CSRF 방어 MUST**) 요건을 실제로 만족하는지 **P2 sub-branch 에서 별도 검증***. PKCE 는 P2A D5 가 소유하나 **CSRF/`state` 방어는 어느 노트도 Decision 으로 소유하지 않는다**(P2A 는 미체크 체크리스트 항목으로만 보유) → `#OAUTH-BBA-C3` 의 허용은 *조건부*이므로 D9 의 전제이기도 하다. 소유자 지정 필요 (§TODO).
|
||||
- [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D1 · D2 · D4 — 사용자 매핑/링크 정책 전량 consume. **바뀌면**: §DEM D1·D2, §구현 가이드 §1 4단계, §엣지 표 3~4행 영향. ⚠️ 같은 노트가 realm SMTP 사실("학습 스택은 SMTP 없이 시작 → 자동 폴백")을 **무라벨 단정**으로 보유 → 본 노트 **D10** 과 이중 주장 (`/sync` 수거 대상, owner 는 D10 이어야 함 — owner 노트가 realm 사실을 자기 범위 밖으로 명시 배제했으므로).
|
||||
- [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D3 · D4 · D5 — attribute 매핑·Sync Mode consume. **바뀌면**: §DEM D3·D6, §claim mapping 표 정정 주석, §구현 가이드 §1 5단계 영향.
|
||||
- [[raw/branch-notes/feature-keycloak-idp-brokering-google-client]] D1~D7 — Google client 등록·scope·trustEmail consume. **바뀌면**: §구현 가이드 §1 1~3단계 전량 영향.
|
||||
- [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] D1 — link key = `sub`. **바뀌면**: §claim mapping 표 `sub` 행 + D6 영향.
|
||||
- [[raw/branch-notes/feature-keycloak-three-leg-trust-chain]] D1 · D2 · D5 — 3-leg 검증 매트릭스 consume (자식). **바뀌면**: §DEM D7, §신뢰 경계 영향.
|
||||
- [[raw/branch-notes/feature-keycloak-federation-spa-zero-change]] D1 — idpHint 미사용 (자식). **바뀌면**: §구현 가이드 §2 의 "SPA 로그인 트리거 0 줄" 논증이 깨짐 (idpHint 를 쓰면 SPA 코드가 바뀜 → D8 의 zero-change 주장 약화).
|
||||
- [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] D1 · D2 — token 저장 위치. **바뀌면**: D9 의 위협 모델 전제 영향.
|
||||
- [[raw/branch-notes/feature-keycloak-bff-vs-spa-direct]] D1 — SPA Direct 학습 1순위 채택. **바뀌면**: D9 의 상위 전제가 무너짐 (BFF 로 이동 시 P2B 자체가 P1B 로 대체됨).
|
||||
- [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]] D1/D3 — role mapper override와 `hd` 정책을 consume한다. IdP-level default `IMPORT`와 role mapper override `FORCE`는 적용 계층이 분리됐다.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> P2B 는 문서/다이어그램 단계 (`documented-only`). 실 구현 시 검증해야 할 동작:
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| ~~Keycloak 의 default first-broker-login flow 가 "Automatically Link" 위험 동작을 가짐 (변경 필요)~~ | **(2026-07-17) 해소 — 주장이 틀렸음.** `#KC-FBLVERIFY-C4` 의 공식 WARNING 상 AutoLink 는 *기본값이 아니라 별도 opt-in dangerous authenticator*. owner [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D1 이 정정 보유 | (검증 불필요 — 전제 오류) 잔여 검증은 owner 의 Claims To Verify 로 이관: AutoLink authenticator 의 정확한 추가 위치 확인 | `resolved-corrected` |
|
||||
| ~~Mapper Sync Mode = FORCE 시 Google 측 name/picture 변경이 다음 로그인 시 overwrite~~ | **(2026-07-17) 위임.** Sync Mode 는 본 노트 소유 아님 → owner [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] D3 (IMPORT) 이 `#KC-SYNCMODE-C3`/`#KC-SYNCMODE-C4` 로 정의 보유 | owner 의 Claims To Verify 로 이관 (Admin UI 드롭다운 라벨 + 실동작 캡처) | `delegated` |
|
||||
| "Confirm Link Existing Account" authenticator 로 변경 시 사용자가 기존 비밀번호 입력 후에만 link 진행 | **(2026-07-17 갱신)** SMTP 설정 realm 은 email 검증이 기본(`#KC-FBLVERIFY-C1`) → "비밀번호 입력"은 email authenticator 를 DISABLE 해야 관철됨(`#KC-FBLVERIFY-C2`). 본 노트 원문 전제가 부정확 | flow copy 후 email authenticator DISABLE → 같은 email local user 의 비밀번호 prompt 확인. **owner 소유** — [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D2 | `delegated` |
|
||||
| Google ID token 의 `email_verified` claim 이 항상 존재하고 boolean (Personal vs Workspace 차이 없음) | `GOOGLE-OIDC-C5` 는 `nonce` 만 verbatim. `email_verified` 자체의 always-present 보장은 본 인용에 없음 | 실 Personal Google account + Workspace account 두 가지로 federation → Keycloak Events 로그에서 claim 값 확인 | `needs-confirmation` |
|
||||
| Backend Spring Security 가 Keycloak access token 의 `iss` claim 으로 (Google 이 아닌) Keycloak realm URL 만 신뢰 | `SSRS-JWT-C1`/`C2` 는 `issuer-uri` 자동 검증을 보장. 그러나 Google token 이 우회 경로로 backend 에 도달 가능한지는 별도 위협 모델 | Google ID token 을 직접 backend `/api/me` 에 제출 → 401 응답 확인 (issuer mismatch) | `planned` |
|
||||
| Advanced Claim to Role mapper 로 `hd` claim → role 매핑 시 personal account (no `hd`) 가 role 미부여 상태로 통과 | `KC-IDP-MAPPER-C4` 는 mapper 종류 목록이 verbatim 부재. `GOOGLE-OIDC-C7` 은 `hd` 부재 처리를 직접 다루지 않음 | Personal Gmail 로 로그인 → Keycloak user 의 realm role / attribute 확인 | `needs-confirmation` |
|
||||
| XSS 시 SPA 의 access token 탈취 가능 (P1B 대비 P2B 의 본질적 약점) | `OWASP-HTML5-C1`/`C2`/`C3` 는 storage XSS 위협을 보장. memory 보관 token 도 동일 위협인지는 추가 reasoning 필요 | 의도적 XSS payload 주입 (테스트 페이지) → `document.cookie` 또는 `window.tokenStore` 접근 가능성 확인 | `planned` |
|
||||
| **(D9)** federation 추가가 P2B 의 token 위협 모델을 **바꾸지 않는다** (직교성) — 즉 `#OAUTH-BBA-C4` 의 보안 순서가 Google IdP 유무와 무관하게 성립 | `OAUTH-BBA-C4` 는 세 패턴의 보안 순서를 규정하나 **federation 유무를 변수로 다루지 않는다** — 직교 주장은 본 노트의 추론 | P1B/P2B 의 위협 목록을 나란히 작성해 brokering 이 추가하는 위협(Google client_secret, 3-leg)이 **token 보유 축과 독립**임을 표로 대조 | `planned` |
|
||||
| **(D8)** Google consent 거부 시 SPA 가 받는 최종 상태 (Keycloak 로그인 화면 복귀 vs SPA 로 error redirect) | brokering 투명성의 실패측 동작이 어느 인용에도 없음 | Google consent 화면에서 "취소" → 브라우저 최종 URL + SPA 상태 관찰 | `needs-confirmation` |
|
||||
| **(D9)** **P2B 가 IETF draft 의 Browser-based OAuth 2.0 Client 정의에 실제로 대응하는가** — D9 의 핵심 전제 | `#OAUTH-BBA-C3` 의 정의(브라우저 앱 = public client, 모든 OAuth 책임을 브라우저에서, resource server 와 직접 통신)와 P2B 서술이 **축자 일치**하나, 근거 raw 의 "Does not prove" 가 *P2B 포함 6조합의 3분류 1:1 대응*을 부인 → 매핑은 **본 노트의 판정**이지 표준 확정이 아님 | draft §6.3 정의와 P2B 구성을 요건별로 1:1 대조 (public client 여부 · client credentials 부재 · token 직접 보유 · RS 직접 호출). 추가로 §6.3.2 의 **PKCE MUST / CSRF 방어 MUST** 충족 여부 확인 — draft 가 "P2 sub-branch 에서 별도 검증"으로 본 노트류를 명시 지목 | `planned` |
|
||||
| **(D9)** **P1B(oauth2-proxy / Traefik ForwardAuth) 가 IETF draft 의 BFF 정의에 실제로 대응하는가** — 이 매핑이 있어야 "P2B < P1B" 를 표준 권위로 말할 수 있음 | 근거 raw 가 **명시적으로 부인**: `oauth2-browser-based-apps-ietf-draft.md` "Does not prove" 열 — *oauth2-proxy/ForwardAuth(P1A/P1B)가 draft 의 BFF 정의와 1:1 동일하다는 것 … 매핑 정합성은 별도 확인 필요*. 본 노트의 추론 | draft §6.1 의 BFF 요건(backend = confidential client · 토큰을 쿠키 세션에 보관 · **모든 요청을 프록시**)을 oauth2-proxy 실동작과 1:1 대조. 프록시 요건이 어긋나면 P1B 는 BFF 가 아니라 §6.2 Token-Mediating Backend 이거나 그 외 → 순서 주장 재작성 필요 | `planned` |
|
||||
| **(D8)** **`Hide on Login Page` 토글의 신규 IdP 등록 시 *기본 상태*가 OFF (=버튼 자동 노출)** — D8 의 zero-change 메커니즘 전제 | `#KC-HIDELOGIN-C3` 은 "ON 일 때만 미노출"을 규정할 뿐 **기본값을 진술하지 않음**. 기본이 ON 이면 SPA 가 `kc_idp_hint` 를 보내야 하므로 D8 의 "SPA 코드 0줄" 이 붕괴 | Google IdP 신규 등록 직후 **아무 설정도 건드리지 않은 상태**로 로그인 화면 방문 → "Sign in with Google" 버튼 노출 여부 visual verify. 자식 [[raw/branch-notes/feature-keycloak-federation-spa-zero-change]] 가 동일 검증 소관 | `needs-confirmation` |
|
||||
| **(D10)** **P2B 학습 realm 에 SMTP 가 실제로 미설정** — D2 의 fork 가 Re-authentication 으로 자동 폴백된다는 전제 | **부재 근거로 도출**: [[raw/branch-notes/feature-keycloak-docker-compose-stack]] 이 SMTP/mail 을 일절 구성하지 않음에서 추론했을 뿐, "SMTP 없음"의 명시 선언은 없음 | 스택 기동 후 Admin Console → Realm settings → Email 탭이 비어 있는지 확인 + 같은 email local user 로 링크 시도 → 비밀번호 prompt(Re-auth) 가 뜨는지 관찰 (email 검증 메일이 오면 전제 붕괴) | `needs-confirmation` |
|
||||
| **(0단계)** `CVE-2026-9087` 의 실재·영향 버전·patch 버전 | **raw 출처 부재** — sibling branch-note 2곳의 자기 보고뿐이며 note→note 전이는 근거가 아님 (CLAUDE.md §11) | 공식 advisory(NVD / Keycloak security advisory / 해당 PR)를 `wiki-source-summarizer` 로 `raw/official-docs/` 에 아카이브 → 영향 버전·patch 를 verbatim 확보 후 §구현 가이드 §1 0단계를 FACT 로 승격 | `needs-confirmation` |
|
||||
|
||||
## Audit & Findings
|
||||
|
||||
> 2026-07-17 `/branch-spec` pass 의 감사 결과. **자동 수정하지 않고 기록만** 한다 (`rules/consistency-contract.md` — "적용은 항상 승인 후", "owner 문서 우선").
|
||||
|
||||
| ID | 코드 | 위치 | 내용 | 조치 |
|
||||
|---|---|---|---|---|
|
||||
| CONTRADICTION-1 | `CONTRADICTION` | §외부 근거 "비교 핵심" 마지막 문장 | 본 노트: "default Auto-Link은 보안 위험 → Confirm Link 로 **변경**". 공식(`#KC-FBLVERIFY-C4`) + owner [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] D1: **OOTB 기본이 이미 Confirm Link**, AutoLink 는 별도 opt-in | **해소됨 (2026-07-17, 사용자 판정 = 인라인 정정 마커)** — 원문 verbatim 보존 + 정정 blockquote 추가. Claims To Verify 1행 `resolved-corrected` 로 갱신 |
|
||||
| CONTRADICTION-2 | `CONTRADICTION` + `DUAL_OWNERSHIP` | §claim mapping · [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]] D1 | IdP-level default와 role mapper override를 같은 설정으로 취급했던 drift | **해소 (2026-07-18)** — profile default `IMPORT`는 google-claim owner D3, role mapper override `FORCE`는 role owner D1로 적용 계층 분리. 본 hub는 pointer만 유지 |
|
||||
| RESTATEMENT-1 | `RESTATED_FOREIGN_DECISION` | §claim mapping · §신뢰 경계 | foreign decision 상세 복제 | **해소 (2026-07-18)** — 두 섹션과 delegated D-row를 owner pointer + 한 줄 불변식으로 축소 |
|
||||
| NO_CODE_REPO | (본 pass 로컬 라벨) | 전역 | keycloak-patterns 프로젝트는 코드 repo 부재 — `actually-implemented` 확정 불가 (`src/` grep 대상 없음). ca-tmpl 은 본 브랜치와 무관한 별개 프로젝트 | §구현 가이드를 admin 구성 절차로 작성, 전 항목 `documented-only`/`planned` 유지 |
|
||||
| COVERAGE_EXEMPT | (게이트 결과) | frontmatter | `governing_docs` 부재 + `related_projects: [keycloak-patterns]` (ca-* 아님) → `rules/coverage-gate.md` §7 에 의해 **coverage 면제** | 조치 없음 (정상) |
|
||||
|
||||
### depth 게이트 1회차 findings (2026-07-17 `branch-depth-auditor`, Blocking 1
|
||||
|
||||
| # | 축 | 심각도 | 내용 | 해소 (2회차 반영) |
|
||||
|---|---|---|---|---|
|
||||
| 1 | R1 | **Blocking** | D8(OWNED)의 Supporting Claim 이 `#KC-IDP-BROKER-C1` **L0 1개**뿐 — "IdP 를 붙이면 SPA 가 정말 안 바뀌나"를 되묻게 됨 | **해소** — `#KC-HIDELOGIN-C2`(IdP 구성 시 로그인 페이지에 옵션 자동 노출 = L1 메커니즘) · `#KC-HIDELOGIN-C1` · `#KC-HIDELOGIN-C3`(ON 일 때만 미노출 + `kc_idp_hint` = L2 경계) · `#KC-GIDP-C1` 추가. 토글 기본 상태 caveat 는 Open Risk + Claims To Verify 로 이관 |
|
||||
| 2 | R1 | Should-fix | §장점/단점 이 "P2B < P1B 는 표준의 명시적 순서"라고 단정 — 그러나 `#OAUTH-BBA-C4` 는 **추상 3패턴** 순서만 규정하고, 근거 raw 는 "oauth2-proxy/ForwardAuth ↔ BFF 매핑은 별도 확인 필요"라고 **명시 부인**. 노트가 "주관적 평가가 **아니라**"라고 써서 다음 독자의 검증을 차단 | **해소** — ①표준 확정(P2B ∈ Browser-based Client)과 ②본 노트 추론(P1B ∈ BFF)을 분리 기술 + Claims To Verify 1행 추가. 결론 방향은 ①만으로 유지됨 |
|
||||
| 3 | R2 | Should-fix | D2 가 owner 의 fork(Email vs Re-auth)를 **옮겨오기만 하고 P2B 가 어느 쪽인지 미판정**. owner 는 realm SMTP 사실을 자기 범위 밖으로 **명시 배제** → 결정 변수의 주인이 없음 | **해소** — **D10 신규(OWNED)**: 학습 realm = SMTP 미설정 → `#KC-FBLVERIFY-C3` 폴백으로 Re-auth 자동. D2 선택 조건이 D10 을 참조. 단 SMTP 미설정은 *부재 근거* → Claims To Verify 1행 |
|
||||
| 4 | R4 | Should-fix | 버전 축 누락 — D2 의 core 방어가 `CVE-2026-9087` 로 우회 가능하다고 **형제 2곳이 flag** 하는데, 정작 배선 순서를 소유한 hub 에 버전 전제가 없음 (P2B 전문 버전 언급 0) | **해소(조건부)** — §구현 가이드 §1 에 **0단계**(버전 하한 확인) + §엣지 표 1행 추가. **단 CVE 는 raw 출처 부재(note→note 전언)** → `UNSUPPORTED_DECISION` 라벨 + 버전 번호 FACT 단정 회피 + advisory 아카이브 TODO(우선) |
|
||||
| 5 | R1 | Advisory | §신뢰 경계 위임 고지가 owner(three-leg-trust-chain D5)의 **`UNSUPPORTED_DECISION` 상태를 승계 표기하지 않음** — pointer 는 resolvable 하나 미지지 | **해소** — 고지에 "미지지 상태 승계" 경고 추가 (D7 셀이 이미 쓰던 패턴 적용) |
|
||||
| 6 | R1 | Advisory | D5 의 SPI 트리거 기준("built-in 이 부족할 때")이 근거 raw 의 "증명하지 않는 것"과 정확히 일치 | **미조치 (수용)** — 노트가 Open Risk 에 자가 flag 중이며 결정 자체는 `#KC-BROKER-SPI-C1`(L1)이 지지. 운영 전환 시 재평가 |
|
||||
|
||||
### depth 게이트 2회차 findings (2026-07-17 재감사 — **Verdict: Ready**, Blocking 0
|
||||
|
||||
1회차 Blocking(D8 L0)은 **실질 해소** 확인 — `#KC-HIDELOGIN-C2` 의 verbatim 이 zero-change 메커니즘을 직접 진술(L1)하고 `#KC-HIDELOGIN-C3` 이 경계를 닫음(L2). 2회차 신규/잔여:
|
||||
|
||||
| # | 축 | 심각도 | 내용 | 해소 |
|
||||
|---|---|---|---|---|
|
||||
| F1 | R1 | Should-fix | 1회차 #2 의 fix 가 **반대편으로 좁혀져 재발** — "①표준이 확정: P2B ∈ Browser-based Client" 라벨 자체가 over-claim. raw 의 Usage Boundaries 는 **P2B 를 포함한 6조합 전부**의 3분류 1:1 대응을 부인 | **해소** — ①을 "표준 확정 = *추상 3패턴의 순서만*", ②P2B 매핑(강함·축자 일치), ③P1B 매핑(약함·raw 가 특정 부인)의 3단으로 분리. "P2B < P1B" 는 ②·③ 모두 필요하므로 단정 불가로 명시. D9 착수 판단은 ②만으로 유지됨을 논증 + P2B 매핑 Claims To Verify 1행 추가(P1B 행과 대칭) |
|
||||
| F2 | R4 | Should-fix | `IMPLICIT_DEPENDENCY` — 노트 정의가 "P2B = P2A + brokering" 이고 §구현 가이드 §2 의 P2A 열 전량이 P2A 소유인데 **P2A 가 의존 목록에 없음**(비교 링크뿐, D-ID 없음) | **해소** — §다른 계약 의존 최상단에 P2A **D1·D3·D4·D5** 행 추가(D5=PKCE baseline, D3=backend 4종 검증 baseline). 부수 발견 2건도 기록: **P2A D4 ↔ 본 노트 D8 의 zero-change 이중 주장**(`/sync`), **CSRF/`state` 방어 무소유**(draft §6.3.2 MUST + raw 가 "P2 sub-branch 에서 별도 검증"으로 지목 → §TODO) |
|
||||
| F3 | R2 | Should-fix | **D10 추가(1회차 #3 fix)의 부산물** — 소유 목록 2곳(`§역할 blurb`·`§DEM 서두`)이 "D5·D8·D9 **뿐**"으로 남아 D10 이 외래 재진술로 오인·삭제될 위험. 배타적 열거라 단순 오타 이상 | **해소** — 2곳 모두 "D5 · D8 · D9 · D10" 으로 갱신 |
|
||||
| A1 | R1 | Advisory | D10 의 "Re-auth **자동 폴백**" 은 `#KC-FBLVERIFY-C3` verbatim("Use this authenticator if the email authenticator is not available")이 **관리자 지침문**이지 런타임 서술이 아님 — 인용된 어느 claim 도 Re-auth 의 기본 등급을 진술 안 함 | **미조치 (수용)** — 인용 출처 절이 "§**Default** first login flow authenticators" 이고 Claims To Verify 가 "비밀번호 prompt 관찰"로 이미 경험적 포착. 실 스택에서 해소 |
|
||||
| A2 | R3 | Advisory | 0단계가 **비교 임계값이 없어 자력 해제 불가**한 게이트 — P2B 배선 전체가 외부 조사 TODO 에 종속 (결함 아닌 일정 리스크) | **미조치 (수용)** — 해제 경로(advisory 아카이브 TODO, 우선)가 이미 명시됨 |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 아직 없음(문서 단계).
|
||||
|
||||
## 묶음 (자식 sub-sub-branches)
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/google-oidc-discovery-spec]]
|
||||
- [[raw/official-docs/keycloak-first-broker-login-flow]]
|
||||
- [[raw/official-docs/keycloak-identity-broker-spi]]
|
||||
- [[raw/official-docs/keycloak-identity-provider-mappers]]
|
||||
- [[raw/official-docs/owasp-html5-storage-xss-spa]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
- [[raw/branch-notes/feature-keycloak-federation-spa-zero-change]]
|
||||
- [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]]
|
||||
- [[raw/branch-notes/feature-keycloak-three-leg-trust-chain]]
|
||||
- [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]]
|
||||
|
||||
> Sources는 상단 "외부 근거" 섹션. Errors/Interview/Lectures/Blog 는 현재 없음.
|
||||
|
||||
## 관련
|
||||
|
||||
- root: [[raw/branch-notes/feature-keycloak-patterns]]
|
||||
- P2A (no Google) 비교: [[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]]
|
||||
- P1B (Edge + Google, brokering 흐름 동일): [[raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation]]
|
||||
- P3B (Single EC2 + Google) 비교: [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]]
|
||||
- **(2026-07-17 추가) 결정 owner 브랜치** (본 노트가 consume):
|
||||
- [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] — 사용자 매핑/링크 정책 (D1·D2·D4)
|
||||
- [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] — attribute 매핑·Sync Mode (D3·D4)
|
||||
- [[raw/branch-notes/feature-keycloak-idp-brokering-google-client]] — Google client 등록·scope·trustEmail (D1~D7)
|
||||
- [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] — link key `sub` (D1)
|
||||
- [[raw/branch-notes/feature-keycloak-bff-vs-spa-direct]] — BFF vs SPA Direct (D1)
|
||||
- [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] — token 저장 위치 (D1·D2)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 머지/종료 시점에 채움.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경:
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목:
|
||||
- `locally-verified` 항목:
|
||||
- `prod-verified` 항목:
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
- 본 노트 전량 — 코드 repo 부재로 `documented-only`/`planned` (§Audit `NO_CODE_REPO`)
|
||||
+466
@@ -0,0 +1,466 @@
|
||||
---
|
||||
title: branch / feature-keycloak-internal-spa-direct-no-google (P2A Internal SPA + Resource Server, no Google)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-CHILD-D594F009
|
||||
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-internal-spa-direct-no-google
|
||||
parent_branch: feature-keycloak-patterns
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, auth, oauth2, oidc]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 118c42959d56467a19dd0f6cc00f7c9c6f5fec89851f3bff46970ef6c12d4cbf
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-internal-spa-direct-no-google — P2A Internal SPA + Resource Server (no Google)
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/branch-notes/feature-keycloak-patterns]] (root)의 sub-branch.
|
||||
> **P2A**: Keycloak이 cluster-internal에 배치되고, SPA(vanilla JS)가 Keycloak에 **직접** OIDC Authorization Code + PKCE로 토큰을 받아옴. 백엔드는 Spring Security Resource Server — JWT 서명·`iss`·`aud`·`exp` 검증만 수행. **Edge proxy 없음.** Google federation 없음.
|
||||
> OWASP / OAuth 2.1 권고: SPA + API 패턴의 **표준형**.
|
||||
> **축 재편 (2026-07-14)**: hub [[raw/project-notes/keycloak-patterns-overview]] §2.3 에서 P2A → **AP1** (Browser-based OAuth Client = SPA-direct + Resource Server) + cross-cutting `배포=cluster-internal` 로 re-map 됨. 본문의 "P2A" 프레이밍은 Phase 0 표기이며, rename/re-parent 은 hub §2.3 대로 `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 변형이다 | Google 없는 SPA Direct 패턴을 AP1과 internal deployment 변형으로 분류한다 | [[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 -->
|
||||
## 목표
|
||||
|
||||
SPA가 Keycloak에 직접 OIDC + PKCE로 토큰을 받고, 백엔드는 JWT 검증만 하는 패턴을 토큰 교환 sequence와 신뢰 경계 수준까지 명확히 설명할 수 있게 한다.
|
||||
|
||||
핵심 질문:
|
||||
|
||||
- **왜 PKCE가 SPA에서 의무인가?** (public client → client secret 보관 불가 → authorization code 탈취 위험 → PKCE로 code-to-token binding)
|
||||
- **백엔드는 무엇을 검증해야 하는가?** (signature via JWKS / `iss` / `aud` / `exp`)
|
||||
- **token custody policy는 무엇인가?** ([[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] D1 — pure SPA token custody policy)
|
||||
- **TMB 대안 경계는 무엇인가?** ([[raw/branch-notes/feature-keycloak-bff-vs-spa-direct]] D2 — TMB alternative boundary)
|
||||
|
||||
OAuth 2.1 draft가 implicit flow를 제거하고 PKCE를 모든 authorization code flow에 의무화한 이유를 SPA 관점에서 정리.
|
||||
|
||||
- 이슈: (학습 노트, 이슈 없음)
|
||||
- PR: (구현 없음 — D8 에 의해 문서 전용)
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- **패턴 정의 (본 branch 고유 소유)** — SPA 가 public client 로 직접 OIDC Authorization Code + PKCE 를 수행하고 백엔드는 Resource Server 로 JWT 검증만 하는 경계 확정: *누가 토큰을 보유하고 누가 검증하는가* (D1). 자식 5개와 형제 branch 가 이 정의를 기준선으로 인용한다.
|
||||
- **cluster-internal 배치의 URL 경계** — 브라우저가 도달하는 frontchannel public URL 과 백엔드가 JWKS 를 조회하는 backchannel internal URL 의 분리, 그리고 `iss` 를 frontchannel 로 고정해야 하는 이유 (D7). 본 branch 의 배포 축(cluster-internal)에서만 발생하는 고유 관심사.
|
||||
- **Google federation 제외 범위 확정** — realm 내부 사용자만 (D4). brokering 비교는 P2B 소관.
|
||||
- **문서 산출물** — 토큰 교환 sequence · 신뢰 경계 · P1A 대비 trade-off 표 (모두 `documented-only`).
|
||||
- **자식 sub-sub-branch 로의 결정 위임 맵** — PKCE 단계 / 백엔드 validator / 토큰 저장 / rotation / BFF 비교의 owner 지정 (§구현 가이드 §3, `rules/consistency-contract.md` Single-Owner 준수).
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- **실 구현 / 배포** — hub [[raw/project-notes/keycloak-patterns-overview]] §5 의 고정 결정 F5 에 의해 4 패턴 E2E 는 single-EC2 docker-compose **1벌로만** 구현하고, cluster-internal 은 hostname·issuer·network 차이만 문서화한다 (D8). 실 구현 대상은 [[raw/branch-notes/feature-keycloak-single-ec2-no-google]] (P3A).
|
||||
- **PKCE 4단계 메커니즘 상세** (verifier/challenge 생성·검증 공식) — [[raw/branch-notes/feature-keycloak-pkce-flow-stages]] D1 소관.
|
||||
- **백엔드 JWT validator 구현 상세** (audience validator, `issuer-uri` wiring, JWKS cache) — [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1 소관.
|
||||
- **토큰 저장 위치 상세** (메모리 / cookie / localStorage 비교) — [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] D1 소관.
|
||||
- **refresh rotation / revocation 상세** — [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] D1 소관.
|
||||
- **BFF 대안 비교 상세** — [[raw/branch-notes/feature-keycloak-bff-vs-spa-direct]] D1 소관.
|
||||
- **Google IdP brokering** — [[raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation]] (P2B) 소관.
|
||||
- **인가(RBAC) — role → `@PreAuthorize`** — hub §5 의 deferred(authZ) 트랙. 본 branch 는 authN 토큰 흐름까지만.
|
||||
- **`iss` mismatch 함정의 재현·해결 절차** — [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] D1(`KC_HOSTNAME` 고정) + D4(실패 먼저 재현) + **D6**(해결 메커니즘 선택) 소관(single-EC2 맥락). 본 branch 는 cluster-internal 의 URL *경계 정의*까지만 (D7).
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 본 sub-branch의 P2A (Internal SPA + Resource Server, no Google) 채택 근거. 상세 대안 비교는 §외부 근거 / 대안 조사 참조.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/oauth2-pkce-rfc-7636]] | RFC 7636 PKCE — public client 필수 PKCE 채택 근거 |
|
||||
| [[raw/official-docs/oauth-v2-1-draft-ietf]] | OAuth 2.1 draft — Auth Code + PKCE 채택 근거 |
|
||||
| [[raw/official-docs/spring-security-resource-server-jwt]] | Spring Security Resource Server JWT 검증 — backend JWT validator 근거 |
|
||||
| [[raw/official-docs/keycloak-securing-apps-overview-official]] | Keycloak Securing Apps overview — 보안 모델 근거 |
|
||||
| [[raw/official-docs/owasp-html5-storage-xss-spa]] | OWASP HTML5 Storage + XSS — token 저장 위치 trade-off 근거 |
|
||||
| [[raw/company-tech-blogs/curity-bff-pattern-spa]] | Curity BFF pattern — BFF 대안 검토 (참고) |
|
||||
| [[raw/official-docs/keycloak-hostname-configuration]] | Keycloak Hostname v2 — cluster-internal 의 frontchannel/backchannel URL 분리 + `iss` 고정 근거 (D7, 2026-07-17 추가) |
|
||||
| [[raw/official-docs/keycloak-client-pkce-method-enforcement-official]] | D5 (PKCE S256 강제)의 Keycloak vendor 측 근거 — Admin UI "PKCE method" 옵션의 정확한 명칭/위치/선택지별 동작 (2026-07-17 추가) |
|
||||
|
||||
## 외부 근거 / 대안 조사 (2026-05-25 — P2A Internal SPA + Resource Server)
|
||||
|
||||
본 sub-branch의 **SPA Direct OIDC + Backend Resource Server (JWT 검증)** 채택에 대한 외부 source. OAuth 2.1 권고 패턴.
|
||||
|
||||
- **채택 결정 (Authorization Code Flow + PKCE + Resource Server JWT validation)**:
|
||||
- [[raw/official-docs/oauth2-pkce-rfc-7636]] — RFC 7636 PKCE (public client 필수)
|
||||
- [[raw/official-docs/oauth-v2-1-draft-ietf]] — OAuth 2.1 draft (PKCE mandatory, implicit grant 제거)
|
||||
- [[raw/official-docs/spring-security-resource-server-jwt]] — Spring Security Resource Server JWT 검증 (issuer-uri, JwtDecoder, audience validator 추가 필요)
|
||||
- [[raw/official-docs/keycloak-securing-apps-overview-official]] — Keycloak Securing Apps overview
|
||||
- [[raw/official-docs/owasp-html5-storage-xss-spa]] — OWASP HTML5 Storage + XSS in SPA (token 저장 위치 고민)
|
||||
- [[raw/company-tech-blogs/curity-bff-pattern-spa]] — Curity BFF pattern article
|
||||
- **검토한 대안**:
|
||||
- **대안 1: Edge ForwardAuth (P1A)** — 백엔드는 인증 코드 0, header 신뢰. 비교 sub-branch: [[raw/branch-notes/feature-keycloak-edge-forwardauth-no-google]].
|
||||
- **대안 2: BFF (Backend-for-Frontend)** — 백엔드 session cookie + token 백엔드 보유. 장: XSS surface 축소 (token이 SPA에 노출 안 됨), refresh token rotation 안전 / 단: 백엔드 stateful, scale-out 시 session 공유 (Redis 등) 필요.
|
||||
- **대안 3: Implicit Flow** — OAuth 2.1에서 **제거**됨 (token이 URL fragment 노출). 채택 불가.
|
||||
- **대안 4: Resource Owner Password Credentials (ROPC)** — 사용자 credentials를 백엔드가 받음. RFC 6749 deprecated. 채택 불가.
|
||||
- **대안 5: Hybrid Flow (Authorization Code + ID token in fragment)** — OpenID Connect, ID token 빨리 받음. 그러나 token 노출 위험 + 복잡.
|
||||
- **비교 핵심**: SPA Direct OIDC + PKCE는 브라우저가 token custody를 직접 가진다. [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] D1 — pure SPA token custody policy. [[raw/branch-notes/feature-keycloak-bff-vs-spa-direct]] D2 — TMB alternative boundary. audience 검증 방식은 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1을 따른다.
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [ ] 컴포넌트 다이어그램 (mermaid sequence) — 등급: `planned`
|
||||
- [ ] Keycloak SPA client 설정 항목 정리 (public, PKCE S256 enforced, redirect URI, Web Origins) — 등급: `documented-only`
|
||||
- [ ] Spring Security Resource Server `application.yml` snippet — 등급: `documented-only`
|
||||
- [ ] Audience validator (custom `OAuth2TokenValidator<Jwt>`) 코드 sketch — 등급: `documented-only`
|
||||
- [ ] BFF 변형 sequence diagram 추가 — 등급: `planned`
|
||||
- [ ] refresh token rotation flow diagram — 등급: `planned`
|
||||
- [ ] P1A 대비 trade-off 표 (다이어그램 포함) — 등급: `documented-only`
|
||||
- [ ] cluster-internal frontchannel/backchannel URL 경계를 컴포넌트 다이어그램에 반영 (현재 ingress 경로 미표기 — §진행 중 메모 `INGRESS_UNDERSPECIFIED`, D7) — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
작업하며 떠오른 메모. 자유 형식.
|
||||
|
||||
- **`AXIS_DRIFT` (2026-07-17 `/branch-spec` 확인)** — hub 가 2026-07-14 에 분류 primary 축을 *배치×federation 6패턴* → *인증 아키텍처 4패턴(AP1~AP4)* 로 교정([[raw/project-notes/keycloak-patterns-overview]] §2.3)했으나 본 노트 본문은 여전히 Phase 0 의 "P2A" 프레이밍이다. 매핑은 **P2A → AP1 + 배포=cluster-internal**. hub §2.3 이 "실제 rename/re-parent 은 `wiki-doc-author mode=migrate` 로 점진 수행(자동 mv 금지 — wikilink 영향 검토)"라고 명시하므로 본 `/branch-spec` 회차에서는 **본문 재작성 없이 정합 표기만** 추가했다(제목 blockquote + 본 메모). 실제 re-parent 대상은 hub §2.3 이 지목한 자식 2개(`spring-rs-audience-validator`, `spa-token-storage-tradeoff` → AP1 그룹)이며 현재는 cosmetic 이라 미실행.
|
||||
- **본 노트는 pattern hub — 결정 detail 의 owner 가 아니다.** 5개 자식이 각 관심사의 owner 이고([[raw/branch-notes/feature-keycloak-bff-vs-spa-direct]] 등), 자식들은 본 노트의 `D1`(SPA Direct = 브라우저 token 보유 정의)을 기준선으로 역참조한다. 반면 본 노트의 `D2`·`D3`·`D5`·`D6` 은 자식 owner 결정의 *요약*이라 `rules/consistency-contract.md` 의 `RESTATED_FOREIGN_DECISION` 소지가 있다 — owner 가 진화하면 낡은 복제본이 된다. 본 회차에서는 사용자 작성 결정을 덮어쓰지 않고(retro 정책: "일괄 자동 수정 금지, `/sync` fix-plan 으로 점진 수거") §구현 가이드 §3 에 **위임 맵**을 세워 포인터를 명시했다.
|
||||
- **`INGRESS_UNDERSPECIFIED`** — 본 노트 §컴포넌트 다이어그램은 `Browser (SPA) ─── OIDC ───► Keycloak (cluster-internal)` 로 그려져 있으나, 브라우저는 cluster-internal 서비스에 직접 도달할 수 없다. Authorization Code 흐름은 브라우저가 `/auth` 로 **redirect** 되고 `/token` 을 **직접 fetch** 해야 성립하므로 Keycloak frontchannel 은 외부 도달 가능한 경로(ingress)를 가져야 한다. 즉 "cluster-internal" 은 *edge forward-auth 프록시가 없다*(P1 과의 차이)는 뜻이지 *Keycloak 이 도달 불가*라는 뜻이 아니다. 이 경로가 미표기라 `iss` 함정의 발생 지점이 노트상 보이지 않는다 → D7 로 경계를 명시하고, 다이어그램 반영은 §TODO 에 남김.
|
||||
- P1A(Edge ForwardAuth)와의 결정적 차이는 *토큰 보유 주체*다. P1A 는 프록시가 세션을 쥐고 백엔드는 헤더를 신뢰하지만, P2A 는 브라우저가 토큰을 쥐고 백엔드가 JWT 를 직접 검증한다 — 그래서 P2A 는 XSS surface 를, P1A 는 헤더 spoofing 을 각각의 signature 함정으로 갖는다.
|
||||
|
||||
## 컴포넌트 다이어그램
|
||||
|
||||
```text
|
||||
┌──────────────────┐
|
||||
│ Keycloak │
|
||||
│ (cluster- │
|
||||
│ internal) │
|
||||
│ │
|
||||
Browser (SPA, vanilla JS) ─── OIDC ────────►│ /auth /token │
|
||||
◄── tokens ───────│ /certs (JWKS) │
|
||||
└──────────────────┘
|
||||
▲ JWKS fetch (캐싱)
|
||||
│
|
||||
Browser ── Authorization: Bearer <access_token> ─► │
|
||||
┌────────┴─────────┐
|
||||
│ Backend │
|
||||
│ Spring Security │
|
||||
│ Resource Server │
|
||||
│ (JWT validate) │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
> ⚠️ 위 다이어그램은 브라우저 → Keycloak 의 **ingress 경로를 생략**하고 있다(§진행 중 메모 `INGRESS_UNDERSPECIFIED`). 실제 경계는 §구현 가이드 §2 (D7) 참조 — 브라우저는 frontchannel public URL 로, 백엔드는 backchannel internal URL 로 같은 Keycloak 에 도달한다.
|
||||
|
||||
신뢰 경계 (trust boundary):
|
||||
|
||||
- **SPA**: public client. token sink. 사용자 브라우저 환경 — XSS가 발생하면 토큰 노출.
|
||||
- **Keycloak**: Authorization Server. 토큰 발급 / JWKS publish.
|
||||
- **Backend**: Resource Server. **SPA를 신뢰하지 않음** — 모든 요청의 JWT를 signature + `iss` + `aud` + `exp`까지 직접 검증해야 SPA 우회 공격 방지.
|
||||
|
||||
## 토큰 교환 sequence (Authorization Code + PKCE)
|
||||
|
||||
1. **PKCE 준비 (SPA)**:
|
||||
- `code_verifier`: 43~128 byte random string (RFC 7636 §4.1).
|
||||
- `code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))` (S256 method).
|
||||
- `state`, `nonce` random 값 생성 (CSRF/replay 방지).
|
||||
2. **Authorization Request (SPA → Keycloak)**:
|
||||
```text
|
||||
GET /realms/<realm>/protocol/openid-connect/auth
|
||||
?response_type=code
|
||||
&client_id=<spa-client>
|
||||
&redirect_uri=<SPA URL>
|
||||
&scope=openid profile email
|
||||
&state=<random>
|
||||
&code_challenge=<challenge>
|
||||
&code_challenge_method=S256
|
||||
```
|
||||
3. **사용자 로그인** → Keycloak이 redirect with `?code=<auth_code>&state=...`.
|
||||
4. **Token Request (SPA → Keycloak)**:
|
||||
```text
|
||||
POST /realms/<realm>/protocol/openid-connect/token
|
||||
grant_type=authorization_code
|
||||
code=<auth_code>
|
||||
redirect_uri=<SPA URL>
|
||||
client_id=<spa-client>
|
||||
code_verifier=<verifier> ← Keycloak이 SHA256 후 step 2의 challenge와 비교
|
||||
```
|
||||
응답: `access_token` (JWT) / `id_token` (JWT) / `refresh_token` / `expires_in`.
|
||||
5. **토큰 저장 (SPA)**:
|
||||
- [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] D1 — pure SPA token custody policy.
|
||||
- [[raw/branch-notes/feature-keycloak-bff-vs-spa-direct]] D2 — TMB alternative boundary.
|
||||
6. **API 호출 (SPA → Backend)**:
|
||||
```text
|
||||
GET /api/...
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
7. **JWT 검증 (Backend, Spring Security Resource Server)**:
|
||||
- JWKS endpoint(`/realms/<realm>/protocol/openid-connect/certs`)에서 public key fetch + 캐싱.
|
||||
- signature 검증 (`kid` 매칭).
|
||||
- `iss` claim = `https://<keycloak>/realms/<realm>` 일치.
|
||||
- `aud` claim에 backend client id 포함 (custom `OAuth2TokenValidator` 추가 필요).
|
||||
- `exp` / `nbf` 시간 검증 (기본 clock skew 60s).
|
||||
8. **Refresh** (access_token 만료 시): SPA → Keycloak `/token` (`grant_type=refresh_token`) → 새 access_token (+ rotated refresh_token).
|
||||
|
||||
## 장점 / 단점 vs P1A (Edge Forward Auth)
|
||||
|
||||
| 항목 | P2A (Internal SPA + Resource Server) | P1A (Edge ForwardAuth) |
|
||||
|------|--------------------------------------|------------------------|
|
||||
| 백엔드 상태 | **Stateless** (JWT만 검증) | 보통 stateless이나 프록시가 세션 보유 가능 |
|
||||
| 토큰 위치 | [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] D1 — pure SPA token custody policy | 프록시 session policy는 비교 패턴 owner 참조 |
|
||||
| XSS surface | **높음** (브라우저에 token) | 낮음 (httpOnly session cookie) |
|
||||
| 다중 클라이언트 (모바일/IoT) | 동일 access_token 재사용 — **간단** | 모바일은 별도 흐름 필요 |
|
||||
| CORS | 명확 (SPA ↔ Backend 직접) | 프록시 뒤로 가려져 단순 |
|
||||
| 토큰 revocation | 어려움 (JWT stateless — short TTL + refresh rotation에 의존) | 프록시 session 종료로 즉시 |
|
||||
| Keycloak 의존도 | 런타임 JWKS fetch만 (장애 영향 작음) | 프록시 ↔ Keycloak 연결 끊기면 전체 차단 |
|
||||
| 운영 복잡도 | 낮음 (백엔드 1개 + Keycloak) | 중간 (oauth2-proxy/Traefik 추가) |
|
||||
|
||||
## 신뢰 경계 / 보안 체크리스트
|
||||
|
||||
- [ ] **PKCE S256 의무**: public client는 `code_challenge_method=plain` 금지. Keycloak client 설정에서 **`PKCE method` = S256** 강제 (Admin Console → Basic configuration → **Capability Config**). ※ 2026-07-17 정정 — 이 항목은 원래 라벨을 `Proof Key for Code Exchange Code Challenge Method` 로 적었으나 `KC-PKCE-C1` 확인 결과 **부정확**. 값을 비워두면(기본) Keycloak 은 PKCE 를 **강제하지 않는다**(`KC-PKCE-C2`) — 즉 "public client 니까 자동 적용"이 아니라 client 마다 명시 설정이 필요하다. 단 S256 설정이 `plain` 요청을 실제로 *거부*한다는 문장은 공식 문서에 없음 → §Claims To Verify.
|
||||
- [ ] **`aud` 검증**: Spring Security 기본 validator는 `iss` + `exp`만 확인. `audience` claim은 **반드시 custom `OAuth2TokenValidator`로 추가** 검증 (cross-client token reuse 방지).
|
||||
- [ ] **`iss` 검증**: `spring.security.oauth2.resourceserver.jwt.issuer-uri`로 자동 검증.
|
||||
- [ ] **JWKS 캐싱 + 키 로테이션**: 기본 5분 캐시. Keycloak 키 회전 시 자동 갱신.
|
||||
- [ ] **redirect_uri exact match**: Keycloak client 설정에 exact URI 등록. ※ 2026-07-17 정정 — 원래 근거를 "RFC 8252 권고"로 적었으나 RFC 8252 는 *native app* scope 이고, 본 패턴(브라우저 SPA)의 정확한 근거는 이미 본 branch Sources 안에 있는 **`OA21-C5` (OAuth 2.1 §2.3.1) — "권고"가 아니라 `MUST`**: *"Authorization servers MUST reject authorization requests that specify a redirect URI that doesn't exactly match one that was registered."* 등록 제약의 구체 명세는 §구현 가이드 §2 (D7).
|
||||
- [ ] **`state` / `nonce` 검증** (SPA): CSRF / replay 방지.
|
||||
- [ ] **refresh token rotation**: Keycloak Realm Settings → `Revoke Refresh Token: ON` + `Refresh Token Max Reuse: 0`.
|
||||
- [ ] **access_token TTL 짧게**: 5~15분. JWT revocation이 어려우므로 짧은 TTL로 보완.
|
||||
- [ ] **CORS 화이트리스트**: backend가 `Access-Control-Allow-Origin`에 SPA origin만 허용.
|
||||
|
||||
## PKCE 의무 (OAuth 2.1
|
||||
|
||||
OAuth 2.1 draft: *"Clients MUST use code_challenge and code_verifier and authorization servers MUST enforce their use except under the conditions described in Section 7.5.1."* — public client뿐 아니라 모든 client에 의무화. implicit flow는 제거됨.
|
||||
|
||||
RFC 7636 §1: *"OAuth 2.0 public clients utilizing the Authorization Code Grant are susceptible to the authorization code interception attack."* — 브라우저 redirect 단계에서 code가 노출될 수 있고, public client는 client secret이 없으므로 code만 탈취되면 토큰 발급 가능. PKCE는 code-to-token 단계에 verifier 증명을 요구하여 이 공격을 차단.
|
||||
|
||||
## refresh token rotation
|
||||
|
||||
- Keycloak: Realm Settings → Tokens 탭
|
||||
- `Revoke Refresh Token`: ON
|
||||
- `Refresh Token Max Reuse`: 0 (한 번 쓰면 무효)
|
||||
- `SSO Session Idle`: 짧게
|
||||
- 효과: refresh token이 탈취되어도 한 번만 사용 가능. 정상 사용자가 다음 refresh를 시도하면 양쪽 다 거부됨 → 침해 탐지 시그널.
|
||||
- OAuth 2.1: *"If refresh tokens are issued, those refresh tokens MUST be bound to the scope and resource servers as consented by the resource owner."*
|
||||
|
||||
## BFF (Backend-for-Frontend) 비교
|
||||
|
||||
SPA가 직접 토큰을 보유하지 않고 백엔드(BFF)가 OAuth client 역할을 대신 수행하는 변형.
|
||||
|
||||
| 항목 | SPA Direct (본 P2A) | BFF 변형 |
|
||||
|------|---------------------|---------|
|
||||
| 토큰 보관 | [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] D1 — pure SPA token custody policy | 비교 패턴 owner의 server-side custody policy 참조 |
|
||||
| 브라우저 ↔ Backend | `Authorization: Bearer <jwt>` | **httpOnly session cookie** |
|
||||
| XSS로 bearer token 직접 탈취 | 실행 중인 JS memory에서 가능 | bearer token은 브라우저 JS에 노출되지 않음. 단 XSS가 활성 session으로 요청을 대행할 위험은 남음 |
|
||||
| 백엔드 상태 | stateless | **stateful** (session store) |
|
||||
| 다중 클라이언트 (모바일) | 동일 흐름 | 모바일은 별도 OAuth client 필요 |
|
||||
| 권장 (Curity, OAuth 2.1 draft) | 허용 | **권장** (특히 민감 데이터) |
|
||||
|
||||
OAuth 2.1 draft: SPA가 "wish to use client credentials"인 경우 *"the backend for frontend pattern"*을 권고. Curity: *"The only way to protect tokens from being accessed by any malicious code is to keep them away from the browser."*
|
||||
|
||||
본 branch는 SPA Direct 흐름을 학습 목적으로 채택 (canonical OIDC + PKCE 흐름 이해가 우선). BFF는 비교 문서로만 정리.
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- 2026-05-25: P2A는 **SPA Direct (토큰을 브라우저에 보유)**로 정의. BFF는 별도 변형으로 비교만. 이유: 가장 canonical한 OIDC + PKCE 흐름을 먼저 이해하기 위함.
|
||||
- 2026-07-18: [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] D1 — pure SPA token custody policy. [[raw/branch-notes/feature-keycloak-bff-vs-spa-direct]] D2 — TMB alternative boundary.
|
||||
- 2026-05-25: 백엔드 검증은 **`iss` + signature + `exp` + `aud` 4종**. Spring Security 기본에 audience validator를 반드시 추가.
|
||||
- 2026-05-25: Google federation 없음 — Keycloak realm 내부 사용자만. brokering 확장의 zero-change invariant는 [[raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation]] D8이 소유한다.
|
||||
- 2026-07-17: cluster-internal 배치에서 브라우저는 **frontchannel public URL**, 백엔드 JWKS 조회는 **backchannel internal URL** 로 분리하고 `iss` 는 frontchannel 로 고정. 이유: Keycloak 이 frontchannel/backchannel URL 분리를 공식 지원하며(KC-HOST-C1), hostname 미고정 시 fraudulent issuer 위험(KC-HOST-C3). 검토한 대안: (a) 브라우저·백엔드 모두 internal DNS → 브라우저 도달 불가 (b) 양쪽 모두 public URL → 백엔드가 불필요하게 ingress 왕복.
|
||||
- 2026-07-17: 본 branch 는 **`documented-only` 유지** — 실 구현은 hub 고정 결정 F5 에 의해 single-EC2(P3A)로 위임. 이유: 배포 토폴로지는 cross-cutting 이라 인증 아키텍처를 바꾸지 않으므로 실 구현 1벌로 4 패턴 검증이 성립.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지. P2A 는 OAuth 2.1 표준 권고 패턴이라 대부분 `official-standard` 근거.
|
||||
|
||||
> `선택 조건` 열(R2, 2026-07-17 추가): "이 조건일 때 이 결정, 다른 조건이면 어떤 대안". 분기 없으면 `N/A`.
|
||||
>
|
||||
> **Ownership note** — 본 노트는 pattern hub 다. `D1`·`D4`·`D7`·`D8` 만 본 branch 고유 소유이고, `D2`·`D3`·`D5`·`D6` 은 자식 owner 결정의 요약이다(§구현 가이드 §3 위임 맵 · §진행 중 메모 `RESTATED_FOREIGN_DECISION`). 세부는 owner 를 정본으로 본다.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | P2A 를 SPA Direct (브라우저가 token 보유) 로 정의, BFF 는 비교만 | **canonical OIDC + PKCE 흐름 학습이 1차 목표**이거나 모바일/IoT 까지 동일 token 흐름을 재사용해야 하면 SPA Direct. **XSS 민감 데이터(금융/의료)** 이거나 SPA 가 **client credentials 를 써야 하면**(`OA21-C4` 의 §2.1 조건) BFF(AP3)로 전환 — 자식 [[raw/branch-notes/feature-keycloak-bff-vs-spa-direct]] D1 이 같은 분기를 owner 로 상술 | `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C1`, `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C4`, `raw/official-docs/oauth2-pkce-rfc-7636.md#PKCE-RFC7636-C1`, `raw/company-tech-blogs/curity-bff-pattern-spa` (참고) | `official-standard + official-standard + official-standard + company-case-study` | `OA21-C4` 는 BFF 가 "recommended" 라고 명시 — SPA Direct 채택은 canonical 학습 우선순위 기반 trade-off. company-tech-blog (Curity) 는 보조 참고지 best practice 단정 근거 아님 |
|
||||
| D2 | **DELEGATED** — [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] D1 — pure SPA token custody policy | [[raw/branch-notes/feature-keycloak-bff-vs-spa-direct]] D2 — TMB alternative boundary | owner 참조 | `delegated` | owner의 custody risk가 본 패턴에도 적용됨 |
|
||||
| D3 | 백엔드 검증 = `iss` + signature + `exp` + `aud` 4종 (Spring Security 기본에 audience validator 반드시 추가) | **백엔드가 JWT 를 직접 신뢰하는 모든 경우**(AP1 = 본 패턴). 대안은 백엔드가 검증을 아예 안 하는 AP4 Edge forward-auth — 인증을 프록시에 위임하고 헤더를 신뢰할 때만 성립(hub §2.1). 즉 "검증 생략"은 배치를 바꿔야 얻는 선택지지 본 패턴 내 옵션이 아님 | `raw/official-docs/spring-security-resource-server-jwt.md#SSRS-JWT-C1`, `raw/official-docs/spring-security-resource-server-jwt.md#SSRS-JWT-C2`, `raw/official-docs/spring-security-resource-server-jwt.md#SSRS-JWT-C6` | `official-vendor-doc` | `SSRS-JWT-C6` 는 Boot `audiences` property 가 `aud` 검증을 활성화함을 보장. 그러나 본 결정의 "custom `OAuth2TokenValidator` 로 추가" 는 별도 §Configuring Validation 페이지 (인용 범위 밖) — programmatic 방식 검증 필요. **owner 는 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1** — 본 행은 요약 |
|
||||
| D4 | Google federation 없음 — Keycloak realm 내부 사용자만. 확장 시 [[raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation]] D8의 zero-change invariant를 consume | 학습 범위를 realm 내부 사용자로 한정할 때. Google 계정 로그인이 요구되면 P2B로 확장 | `UNSUPPORTED_DECISION` (scope) + owner D8 pointer | `internal-convention + delegated` | 코드 변경량 0은 본 branch가 재결정하지 않으며 owner의 diff 검증 전까지 `planned` |
|
||||
| D5 | PKCE S256 의무 (`code_challenge_method=plain` 금지). Keycloak client 설정에서 PKCE method = S256 강제 | **public client(브라우저 SPA)** 이면 PKCE 자체는 `OA21-C1` 상 조건 없는 MUST. `plain` 은 S256 을 계산할 수 없는 제약 클라이언트에서만 논의 대상이며 브라우저(`crypto.subtle`)에는 해당 없음 — **단 이는 frontchannel = HTTPS 전제에 종속** (`crypto.subtle` 은 secure context 에서만 노출, `localhost` 예외 — §구현 가이드 §2 의 `UNSUPPORTED_IMPL_DECISION` 참조). 그리고 **"plain 금지"의 직접 근거는 아래 Open Risk 참조** | `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/oauth-v2-1-draft-ietf.md#OA21-C1`, `raw/official-docs/keycloak-client-pkce-method-enforcement-official.md#KC-PKCE-C1` | `official-standard` (**부분** — 아래 참조) + `official-vendor-doc` | ⚠️ **EVIDENCE_GAP (2026-07-17 확인, 2026-07-17 부분 해소)**: 인용된 claim 중 어느 것도 "plain 금지 / S256 강제 시 실제 거부"를 증명하지 않는다. `OA21-C1` 은 *PKCE 사용* MUST 일 뿐 method 를 S256 으로 한정하지 않고, `PKCE-RFC7636-C3` 은 S256 *공식*만 제공. Keycloak client 의 강제 옵션(Admin UI 경로/속성명)은 이제 `KC-PKCE-C1`이 커버 — 정식 라벨은 "PKCE method"(Capability Config 섹션)이며 기존 추정 라벨("Proof Key for Code Exchange Code Challenge Method")은 부정확했음이 확인됨. **그러나 S256 설정 시 `plain` 요청을 실제로 거부한다는 문장은 Keycloak 공식 문서에도 없음**(`KC-PKCE-C3` Does not prove) — hands-on 검증 필요, `needs-confirmation` 유지. RFC 7636 §4.2 의 MTI 규정은 여전히 미인용. **owner 는 [[raw/branch-notes/feature-keycloak-pkce-flow-stages]] D1** — 본 행은 요약 |
|
||||
| D6 | refresh token rotation (Revoke Refresh Token: ON + Max Reuse: 0 + 짧은 SSO Session Idle) | **refresh token 을 발급하는 모든 경우**. rotation 없이 장기 refresh 를 두면 탈취 시 만료까지 무기한 재사용 가능(`CURITY-BFF-C6` 이 SPA Direct 의 핵심 위험으로 지목) → 본 패턴에선 대안 없음. rotation 자체가 불필요해지는 유일한 경로는 refresh 를 브라우저에서 제거하는 AP2/AP3 전환 | `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C3` | `official-standard` | `OA21-C3` 은 rotation 빈도/만료 수치를 보장 안 함 → Keycloak 의 실 동작 (한 번 재사용 시 양쪽 token 무효) 은 별도 검증 필요. 본 sub-branch 는 `documented-only`. **owner 는 [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] D1** — 본 행은 요약 |
|
||||
| D7 | cluster-internal 배치의 URL 경계 = 브라우저는 **frontchannel public URL**(ingress), 백엔드 JWKS 는 **backchannel internal URL**, `iss` 는 frontchannel 로 고정 | **Keycloak 이 cluster-internal 이고 브라우저가 직접 OIDC 를 수행하는 본 패턴**에서 적용. 대안 (a) 양쪽 모두 internal DNS → 브라우저가 `/auth` redirect 에 도달 불가하여 흐름 자체가 성립 안 함 (b) 양쪽 모두 public URL → 동작하지만 백엔드 JWKS 가 불필요하게 ingress 를 왕복(`KC-HOST-C1` 이 분리를 지원하는 이유). single-EC2 배포(P3A)면 `KC_HOSTNAME` 단일 host 로 축약 | `raw/official-docs/keycloak-hostname-configuration.md#KC-HOST-C1`, `raw/official-docs/keycloak-hostname-configuration.md#KC-HOST-C2`, `raw/official-docs/keycloak-hostname-configuration.md#KC-HOST-C3`, `raw/official-docs/keycloak-hostname-configuration.md#KC-HOST-C4`, `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C5` (redirect_uri exact-match MUST) | `official-vendor-doc + official-standard` | ⚠️ **중심 명제는 추론 (2026-07-17 depth 감사)**: "`iss` 를 frontchannel 로 고정"은 `KC-HOST-C1`~`C4` 중 **어느 것도 직접 말하지 않는다** — 세 claim 은 분리 capability(C1) / hostname 의무(C2) / fraudulent issuer rationale(C3) / full URL 요구(C4)까지만 보장한다. `iss` ← `KC_HOSTNAME` + realm path 결합 규칙은 해당 raw 의 Usage Boundaries 가 "본 페이지엔 명시 없음"으로 못박았고, 그 결합을 서술한 절도 "자료 직접 인용 아님"으로 자기표시 → **disclosed inference**, §Claims To Verify 로 검증. `KC-HOST-C1` 은 분리 *가능성*만 보장하고 k8s ingress 의 구체 매니페스트는 범위 밖. 함정의 재현·해결은 [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] D1 소관(single-EC2 맥락). `redirect_uri` exact-match 는 owner 부재로 **본 D7 이 흡수**(§구현 가이드 §2) |
|
||||
| D8 | 본 branch 는 `documented-only` 유지 — 실 구현은 single-EC2(P3A)로 위임 | **배포 토폴로지가 인증 아키텍처를 바꾸지 않는 한**(hub §2.2) 실 구현 1벌로 4 패턴 검증. cluster-internal 고유의 실패(예: ingress 경유 `iss` 불일치)를 E2E 로 재현해야 할 요구가 생기면 별도 k8s 환경 branch 로 승격 | `UNSUPPORTED_DECISION` (외부 raw source 없음 — 내부 규약 [[raw/project-notes/keycloak-patterns-overview]] §5 고정 결정 F5 + §2.2 cross-cutting 정의에 근거) | `internal-convention` | F5 는 "auth 아키텍처를 바꾸지 않는다"는 전제 위에 서 있다. D7 이 지적한 frontchannel/backchannel 분리는 single-EC2 에선 축약되므로, **cluster-internal 고유 함정은 E2E 로 검증되지 않은 채 문서로만 남는다** — 면접에서 "직접 해봤나" 질문에 `documented-only` 로 답해야 함 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 본 branch 는 `documented-only` pattern hub (D8) — 실행 코드가 아니라 **패턴 경계의 사전 명세 + 자식 owner 로의 위임 맵**이 산출물이다. 아래 sub-section 은 본 branch 고유 결정(D1·D4·D7·D8)에서만 도출하며, 자식이 owner 인 detail 은 재진술하지 않고 포인터로 위임한다(`rules/consistency-contract.md` Reference-Only). 실 구현 등급은 모두 `planned`.
|
||||
|
||||
### 1. P2A 패턴 경계 명세 — 누가 토큰을 보유하고 누가 검증하는가
|
||||
|
||||
> **Trace**: D1 (SPA Direct 정의 — `OA21-C1`/`PKCE-RFC7636-C1`) + D3 (백엔드 4종 검증 — `SSRS-JWT-C1`/`C2`/`C6`). 본 §가 자식 5개와 형제 branch 가 인용하는 **기준선**이다 — [[raw/branch-notes/feature-keycloak-bff-vs-spa-direct]] D1 이 본 D1 을 SPA Direct 측 기준으로 역참조한다.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음 — 본 표의 각 행은 인용된 claim 의 직접 도출이다.
|
||||
|
||||
| 경계 | 무엇을 보유 / 수행 | 메커니즘 | 근거 | 등급 |
|
||||
|---|---|---|---|---|
|
||||
| 브라우저 (SPA) | access/refresh/id token **전부 보유** — public client (client secret 없음) | Authorization Code + PKCE 를 SPA 가 직접 수행. secret 이 없으므로 code 탈취 방어를 PKCE 가 대신함 | `PKCE-RFC7636-C1` (public client 는 code interception 에 취약), `OA21-C1` (PKCE MUST) | `documented-only` |
|
||||
| Keycloak (AS) | 토큰 발급 + JWKS publish | `/auth` → `/token` → `/certs` | `KC-SECAPP-C1`~`C2` (Keycloak 보안 모델), `KC-HOST-C2` (hostname 고정) ※ 2026-07-17 축소 — 원래 `C1`~`C3` 로 인용했으나 `KC-SECAPP-C3` 은 *verbatim 원문 부재* 자체가 claim 인 행(strength `needs-confirmation`)이라 **긍정 근거로 인용 불가** | `documented-only` |
|
||||
| 백엔드 (Resource Server) | **토큰 미보유** — 요청마다 JWT 를 검증만 | `issuer-uri` 한 줄로 discovery + JWKS fetch + `iss`/`exp` 자동 검증, `aud` 는 별도 추가 | `SSRS-JWT-C1`, `SSRS-JWT-C2`, `SSRS-JWT-C6` | `documented-only` |
|
||||
| 백엔드 ↔ SPA | 신뢰 없음 — Bearer JWT 만 | `Authorization: Bearer <access_token>`, 백엔드는 SPA 의 어떤 주장도 검증 없이 수용하지 않음 | D1 (패턴 정의) + `SSRS-JWT-C1` | `documented-only` |
|
||||
|
||||
### 2. cluster-internal 배치의 URL 경계 (frontchannel vs backchannel)
|
||||
|
||||
> **Trace**: D7 (`KC-HOST-C1` frontchannel/backchannel 분리 지원, `KC-HOST-C2` hostname 의무·dynamic resolution 차단, `KC-HOST-C3` fraudulent issuer 방어). 본 §는 **본 branch 의 배포 축(cluster-internal)에서만 발생하는 고유 관심사**이며, §진행 중 메모 `INGRESS_UNDERSPECIFIED` 를 종결한다. `KC-HOST-C1` 의 "Applies to" 가 "container 내부 vs 외부 access 가 다른 토폴로지 (예: docker-compose, **k8s**)" 로 본 배치를 직접 지목한다.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: ingress 구현체 선택(k8s Ingress / Gateway API / Service `type=LoadBalancer`)은 어느 인용도 권고하지 않는다. trade-off: 본 branch 는 `documented-only`(D8)라 구현체를 고르지 않고 *경계의 존재*만 확정한다 — 실 구현 시 P3A 는 이 경계가 단일 host 로 축약되므로 선택 자체가 소멸한다.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 아래 옵션의 정확한 값 형태(hostname-only vs full URL)는 `hostname-backchannel-dynamic` 활성 여부에 종속되며(`KC-HOST-C4` — "If set to true, `hostname` option needs to be specified as a full URL"), 본 branch 는 실 설정을 하지 않으므로 형태(shape)만 기록한다.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: frontchannel 의 scheme(HTTPS 전제)은 인용이 강제하지 않는다. trade-off: SPA 가 S256 challenge 를 계산하는 `crypto.subtle` 은 브라우저 **secure context** 에서만 노출되므로 frontchannel 이 평문 HTTP 면 D5 의 "브라우저는 항상 S256 계산 가능" 전제가 깨진다 — 학습 환경의 `localhost` 예외를 제외하면 frontchannel = HTTPS 로 둔다. 본 corpus 에 이 브라우저 제약의 직접 인용 없음(`oauth2-pkce-rfc-7636.md` Usage Boundaries 도 "추가 확인 필요"로만 기록).
|
||||
>
|
||||
> **⚠️ 인용 경계 (2026-07-17 depth 감사 반영)**: 아래 `iss` 행의 중심 명제(**`iss` ← frontchannel URL**)는 `KC-HOST-C1`~`C3` 중 **어느 것도 직접 말하지 않는다** — 세 claim 은 (분리 capability / hostname 의무 / fraudulent issuer rationale)까지만 보장한다. `iss` 가 `KC_HOSTNAME` + realm path 로 결합되는 규칙은 해당 raw 의 Usage Boundaries 가 "본 페이지에선 명시 없음"으로 못박았고, 노트가 그 결합을 서술한 절도 "자료 직접 인용 아님"으로 표시돼 있다. 따라서 이 행은 **disclosed inference(추론)** 이며 §Claims To Verify 로 검증 대상이다 — 인용된 사실로 취급 금지.
|
||||
|
||||
| 경로 | 누가 사용 | 어떤 URL | 근거 | 등급 |
|
||||
|---|---|---|---|---|
|
||||
| frontchannel | **브라우저** — `/auth` redirect + `/token` fetch + `redirect_uri` 복귀 | 외부 도달 가능한 public URL (ingress 경유). Keycloak `hostname` 옵션으로 **명시 고정** | `KC-HOST-C1` (public URL for frontchannel), `KC-HOST-C2` (hostname 의무), `KC-HOST-C4` (backchannel-dynamic 시 full URL) | `planned` |
|
||||
| backchannel | **백엔드** — JWKS(`/certs`) 조회 | cluster 내부 service DNS (ingress 미경유) | `KC-HOST-C1` ("enabling internal communication while maintaining the use of a public URL for frontchannel requests") | `planned` |
|
||||
| `iss` claim | 토큰에 각인 → 백엔드가 대조 | **frontchannel URL 로 고정** — 브라우저가 받은 토큰의 발급자가 frontchannel 이므로 백엔드의 기대 issuer 도 동일해야 함 | ⚠️ **추론** (위 인용 경계 참조) — `KC-HOST-C2`/`C3` 는 hostname 고정의 *의무·이유*까지만 보장 | `planned` |
|
||||
| **`redirect_uri` 등록** | **Keycloak client 설정** — 브라우저의 복귀 주소 | **frontchannel public URL 기준의 exact URI**. ingress hostname 이 등록값과 한 글자라도 다르면(scheme·port·trailing slash 포함) authorization request 자체가 거부 | `OA21-C5` (**official-standard MUST** — "Authorization servers MUST reject authorization requests that specify a redirect URI that doesn't exactly match one that was registered") | `planned` |
|
||||
| **Web Origins (CORS)** | **Keycloak client 설정** — SPA 가 `/token` 을 fetch 할 origin | SPA 를 서빙하는 origin. frontchannel URL 과 **다를 수 있음**(SPA=nginx origin, Keycloak=ingress origin) → 두 origin 이 분리되므로 `/token` 호출이 cross-origin 이 되어 Web Origins 등록 필요 | `UNSUPPORTED_IMPL_DECISION` — Keycloak 의 Web Origins 옵션은 본 corpus 에 미인용(`KC-SECAPP-C1`~`C3` 범위 밖). trade-off: §TODO 가 "Web Origins"를 본 hub 산출물로 지정했고 SPA↔Keycloak origin 분리는 본 배치의 구조적 귀결이라 경계만 기록, 옵션 명세는 실 설정 시 확인 | `planned` |
|
||||
| 불일치 시 | 백엔드 401 (`iss`) / Keycloak 거부 (`redirect_uri`) | 백엔드가 backchannel URL 을 기대 issuer 로 설정하면 frontchannel 로 발급된 `iss` 와 mismatch. `redirect_uri` 는 인증 시작 단계에서 즉시 거부 | `iss` 함정 재현·해결은 [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] D1 + D6 위임 (single-EC2 맥락, 동일 원리). `redirect_uri` 는 **owner 부재 → 본 D7 이 흡수** (아래 참조) | `documented-only` |
|
||||
|
||||
> **`redirect_uri` 관심사의 owner 귀속 (2026-07-17 depth 감사 — 후보 2개 배제 후 확정)**: SPA↔Keycloak 의 `redirect_uri` exact-match 는 **어느 형제 branch 도 실제로 소유하지 않음**을 전수 확인했다. 후보와 배제 근거:
|
||||
>
|
||||
> 1. [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]] D1 — *Google 측* Authorized redirect URI(`/broker/google/endpoint`) 소관. Google 이 검증하는 URI 이지 Keycloak 이 SPA 에게 검증하는 URI 가 아니라 **다른 계약**.
|
||||
> 2. [[raw/branch-notes/feature-keycloak-docker-compose-stack]] — hub [[raw/project-notes/keycloak-patterns-overview]] 가 "redirect_uri 함정"의 owner 로 **지목하고 있으나**, 그 노트는 `redirect_uri` 를 **한 번도 다루지 않는다**(2026-07-17 grep: 유일한 "redirect" 매치는 healthcheck 커맨드 줄). → hub 의 해당 포인터는 `STALE_OWNER` 이며 실질 owner 부재.
|
||||
>
|
||||
> 따라서 **본 D7 이 흡수**한다 — frontchannel public URL 이 곧 등록 제약을 결정하므로 D7 의 자연스러운 확장이다. hub 는 이 함정을 P3A 의 "부차 함정"(`localhost` vs `127.0.0.1` mismatch)으로도 지목하고 있어 실 구현 시 동일 원리로 재현된다. **후속(본 branch 밖, `/sync` 대상)**: hub 의 `STALE_OWNER` 포인터를 D7 로 갱신. (~~실 구현 branch [[raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce]] D5 에 `OA21-C5` 근거 역참조 연결~~ → **완료 2026-07-18 `/branch-spec`**: 그 branch D5 가 `UNSUPPORTED_DECISION` 에서 `OA21-C5`(redirect URI exact-match MUST) 직접 인용으로 승격됨. 잔여 임의 detail(단일 callback page 분리)만 `UNSUPPORTED_IMPL_DECISION` 로 강등.)
|
||||
|
||||
### 3. 결정 위임 맵 (자식 owner — Reference-Only)
|
||||
|
||||
> **Trace**: D2·D3·D5·D6 은 본 hub 가 요약만 보유하고 detail 의 owner 는 자식이다(§진행 중 메모 `RESTATED_FOREIGN_DECISION`). `rules/consistency-contract.md` 의 Single-Owner 에 따라 **세부는 owner 를 정본으로 본다** — 본 표는 포인터 + 1줄 요약만 유지하고 임계값·메커니즘·예외 목록을 재진술하지 않는다.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음 — 각 행은 owner 노트의 실존 `D<n>` 을 가리킨다(2026-07-17 확인).
|
||||
|
||||
| 관심사 | owner (정본) | owner 결정 | 1줄 요약 (본 hub 의 인용) |
|
||||
|---|---|---|---|
|
||||
| PKCE 4단계 메커니즘 (verifier/challenge/exchange) | [[raw/branch-notes/feature-keycloak-pkce-flow-stages]] D1 | S256 만 정리 대상, `plain` 은 비교용 1줄 | 본 hub 의 D5 는 이 결정의 요약 — S256 공식·단계별 detail 은 owner 참조 |
|
||||
| 백엔드 JWT validator (`aud` 추가 검증) | [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1 | `iss`+signature+`exp`+`aud` 4종 검증, `aud` 는 custom validator 필수 | 본 hub 의 D3 는 이 결정의 요약 — validator 구현 방식은 owner 참조 |
|
||||
| Keycloak `aud` claim 주입 | [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D4 | Keycloak 은 `aud` 에 backend client_id 를 자동 포함하지 않음 → SPA client scope 에 Audience mapper 등록 필수 | 본 hub §신뢰 경계 체크리스트의 "`aud` 검증" 항목이 성립하려면 발급 측 설정이 선행 |
|
||||
| 토큰 저장 위치 | [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] D1 | pure SPA token custody policy | [[raw/branch-notes/feature-keycloak-bff-vs-spa-direct]] D2 — TMB alternative boundary |
|
||||
| refresh rotation / revocation | [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] D1 | rotation 활성화(`Revoke Refresh Token: ON` + `Max Reuse: 0`) — reuse detection | 본 hub 의 D6 는 이 결정의 요약. access token revocation 즉시성은 owner D2(짧은 TTL) 참조 |
|
||||
| BFF 대안 비교 | [[raw/branch-notes/feature-keycloak-bff-vs-spa-direct]] D1 | SPA Direct 를 학습 1순위로 채택, BFF 는 비교 문서로만 | 본 hub §BFF 비교 표의 정본. 결정 기준 매트릭스는 owner §구현 가이드 §3 참조 |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4(깊이 게이트) 캡처용. 본 branch 는 `documented-only`(D8) 이나, 패턴을 실제로 세울 때 부딪힐 실패/엣지와 다른 계약 의존을 미리 열거한다.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **`iss` mismatch (본 배치의 signature 함정)**: 브라우저는 frontchannel URL 로 토큰을 받고 백엔드는 backchannel URL 을 기대 issuer 로 설정하면 모든 요청이 401. 기대 동작: `iss` 를 frontchannel 로 고정(D7)하고 백엔드 `issuer-uri` 도 동일 값. 근거: `KC-HOST-C1`/`C2`/`C3`. 재현·해결 절차는 [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] D1 + D6 위임 — (2026-07-17) 그 branch **D6** 가 해결 메커니즘의 선택 기준을 정함. 현재 기본은 **(C) Docker `extra_hosts`** 이고, **(F) Spring `issuer-uri`/`jwk-set-uri` 분리**(`SSRS-JWT-C5` — 두 값을 같게 만들 필요 자체가 없음)는 **근거 있는 권고이나 미승인**(owner 인 P3A D3 미갱신). F 가 승인되면 본 D7 의 "`iss` 는 frontchannel 고정 + 백엔드 `issuer-uri` 도 동일 값" 전제와 **양립**한다(백엔드는 `issuer-uri` 를 frontchannel 로 두고 `jwk-set-uri` 만 backchannel 로 분리) — 즉 본 D7 은 F 승인 여부와 무관하게 유효.
|
||||
- **`aud` 미검증 → cross-client token reuse**: Spring 기본 validator 는 `aud` 를 보지 않으므로(`SSRS-JWT-C6` 의 범위) 같은 realm 의 다른 client 토큰이 본 백엔드에서 통과. 기대 동작: audience validator 로 401. 위임: [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1 + D4(발급 측 Audience mapper 선행).
|
||||
- **XSS 1건 = 세션 전체 탈취**: 브라우저가 token을 쥐는 것이 본 패턴의 정의(D1)이므로 XSS는 owner 정책의 위험을 그대로 가진다. [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] D1 — pure SPA token custody policy. [[raw/branch-notes/feature-keycloak-bff-vs-spa-direct]] D2 — TMB alternative boundary.
|
||||
- **refresh token 재사용 탐지**: rotation ON 상태에서 탈취자와 정상 사용자가 같은 refresh 를 쓰면 양쪽 다 거부 → 침해 시그널이자 **정상 사용자의 강제 로그아웃**(가용성 비용). 기대 동작: 재로그인 유도. 위임: [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] D1.
|
||||
- **Keycloak 미가용**: 백엔드는 JWKS 를 캐시하므로 이미 발급된 토큰은 캐시 유효 동안 계속 검증 가능하나(§신뢰 경계 체크리스트의 "기본 5분 캐시" 주장은 **미검증** — [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D5 가 `UNSUPPORTED_DECISION` 으로 명시), 신규 로그인은 즉시 차단. P1A 와 달리 프록시가 없어 기존 요청은 계속 처리됨 — §장점/단점 표의 "Keycloak 의존도: 장애 영향 작음"이 이 뜻.
|
||||
- **CORS preflight 실패**: 본 패턴은 SPA 가 백엔드를 **직접** 호출하므로(P1A 는 프록시 뒤라 동일 origin) `Access-Control-Allow-Origin` 화이트리스트가 없으면 브라우저가 요청을 차단. 기대 동작: SPA origin 만 허용. `UNSUPPORTED_IMPL_DECISION` — 본 branch Sources 에 CORS 직접 인용 없음(§신뢰 경계 체크리스트의 분석 통찰). trade-off: 일반 브라우저 동작 원리로 성립하나 official 단정 불가.
|
||||
- **ingress 부재 → 흐름 자체 불성립**: Keycloak frontchannel 이 외부 도달 불가하면 `/auth` redirect 단계에서 실패. 기대 동작: ingress 경로 확보(D7). 본 노트 다이어그램이 이 경로를 생략하고 있음(`INGRESS_UNDERSPECIFIED`).
|
||||
- **`redirect_uri` mismatch → 인증 시작 단계에서 거부**: `iss` 를 맞춰도 그 앞에서 깨지는 경로다. ingress hostname 이 Keycloak client 에 등록된 URI 와 정확히 일치하지 않으면(scheme·port·trailing slash·`localhost` vs `127.0.0.1` 포함) authorization request 가 거부되고 토큰 교환까지 가지도 못한다. 기대 동작: frontchannel public URL 기준 exact URI 등록(§구현 가이드 §2). 근거: `OA21-C5` (**MUST** — 본 branch Sources 안의 official-standard). **owner 부재 → 본 hub 의 D7 이 흡수**(형제 [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]] D1 은 Google 측 `/broker/google/endpoint` 소관이라 위임 불가). hub [[raw/project-notes/keycloak-patterns-overview]] 가 같은 함정을 P3A 의 "부차 함정"으로 지목.
|
||||
- **`state` / `nonce` 불일치 → callback 단계 중단**: §토큰 교환 sequence step 1 과 §신뢰 경계 체크리스트가 `state`/`nonce` 를 CSRF/replay 방어로 2회 선언하지만, 검증 실패 시 SPA 동작은 미정의였다. 기대 동작: **조용한 재시도 금지** — `state` 불일치는 CSRF 시도의 신호이므로 code 를 교환하지 말고 흐름을 중단 + 재로그인 유도(재시도는 공격자가 심은 code 를 소비시킬 수 있음). `nonce` 는 `id_token` 검증 시 대조. **owner 부재** — §구현 가이드 §3 위임 맵에 해당 관심사가 없고 `UNSUPPORTED_IMPL_DECISION`: 본 branch Sources 에 `state`/`nonce` 실패 처리의 직접 인용 없음(`OA21-C1`~`C6` 는 PKCE·redirect·refresh binding 까지). trade-off: 중단이 보수적 선택이라 채택하되, 근거는 실 구현(P3A [[raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce]]) 시 OAuth 2.1 §7 계열 인용으로 보강 필요.
|
||||
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/project-notes/keycloak-patterns-overview]] §5 의 고정 결정 **F5**(실 구현 배포 = single-EC2 1벌) — 본 branch 의 D8 이 여기에 직접 의존. F5 가 바뀌어 cluster-internal E2E 가 요구되면 D8 이 무효화되고 본 branch 는 실 구현 branch 로 승격.
|
||||
- [[raw/project-notes/keycloak-patterns-overview]] §2 의 고정 결정 **F1**(패턴 taxonomy = AP1~AP4) — 본 branch 는 AP1 + 배포=cluster-internal 로 매핑됨. branch 에서 재정의 금지(SSOT 는 hub).
|
||||
- [[raw/project-notes/keycloak-patterns-overview]] §5 의 고정 결정 **F3**(단일 realm + 패턴당 client 1개) — D3 의 `aud` 검증이 성립하는 전제. client 를 분리하지 않으면 audience 로 client 를 구분할 수 없음.
|
||||
- [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1 + D4 — 본 hub 의 D3 요약이 의존. owner 가 검증 4종 구성이나 Audience mapper 요구를 바꾸면 본 hub 의 §신뢰 경계 체크리스트도 갱신 필요.
|
||||
- [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] D1 — pure SPA token custody policy. 본 hub의 D2와 sequence step 5는 owner 변경 시 포인터 의미만 재확인한다.
|
||||
- [[raw/branch-notes/feature-keycloak-bff-vs-spa-direct]] D2 — TMB alternative boundary.
|
||||
- [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] D1 + D2 — 본 hub 의 D6 요약이 의존. rotation 정책이 바뀌면 §refresh token rotation 절과 §장점/단점 표의 revocation 행이 영향.
|
||||
- [[raw/branch-notes/feature-keycloak-pkce-flow-stages]] D1 — 본 hub 의 D5 요약이 의존. S256 범위 결정이 바뀌면 §PKCE 의무 절 영향.
|
||||
- [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] D1 + D6 — D7 의 함정 재현(D1·D4)·해결(**D6**)을 위임. 그 branch 는 `parent_branch: feature-keycloak-single-ec2-no-google`(P3A) 이지만 hub 분해표상 **AP1 그룹** 이라 본 패턴과 같은 인증 아키텍처를 공유한다.
|
||||
- [[raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation]] D8 — brokering zero-change invariant의 owner. 본 D4는 scope와 owner pointer만 유지한다.
|
||||
- [[raw/branch-notes/feature-keycloak-edge-forwardauth-no-google]] (P1A) — §장점/단점 표의 비교 대상. 계약 의존은 아니나 "토큰 보유 주체"의 경계 구분 유지 필요.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> OAuth 2.1 권고는 표준이지만, Spring Security + Keycloak 결합 시 실제 동작은 별도 검증.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| Spring Boot 의 `spring.security.oauth2.resourceserver.jwt.audiences` property 가 `aud` 검증을 자동 활성화 (programmatic validator 불필요) | `SSRS-JWT-C6` 는 Boot `audiences` property 의 존재를 보장하나, 본 sub-branch 의 결정 D3 은 "custom `OAuth2TokenValidator` 로 audience 추가" 라고 적혀 있음 → 두 방식 중 어느 쪽이 권장인지 불명확 | Spring Boot 3.x sample 에서 `application.yml` 에 `audiences` 만 설정 → 잘못된 audience JWT 제출 시 401 응답 확인 | `needs-confirmation` |
|
||||
| **Keycloak client 의 `PKCE method = S256` 설정이 `code_challenge_method=plain` 요청을 실제로 *거부* 하는지** (D5 의 잔여 갭 — 이것만 남았음) | 2026-07-17 확인: Keycloak **공식 문서에도 거부 문장이 없다**. `KC-PKCE-C3` 는 "Keycloak applies to the client PKCE whose code challenge method is S256" 까지만 말하고 rejection semantics(error code / HTTP status)를 서술하지 않으며, 그 raw 의 Does-not-prove 열이 이를 명시. owner [[raw/branch-notes/feature-keycloak-pkce-flow-stages]] D1 의 Open Risk 도 동일 결론 → **문헌으로는 닫히지 않음, hands-on 만이 종결** | Admin Console → Basic configuration → Capability Config 에서 `PKCE method = S256` 설정 후 `code_challenge_method=plain` 으로 authorize request → 거부 여부 + 실제 error code 관찰. 실행 시점: P3A [[raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce]] 구현 시 | `needs-confirmation` |
|
||||
| RFC 7636 §4.2 의 S256 **MTI(Mandatory To Implement) 규정**이 corpus 에 미인용 — D5 의 "plain 금지" 중 *표준 측* 근거 | 2026-07-17 `/branch-spec` 확인: `OA21-C1` 은 PKCE *사용* MUST 일 뿐 method 한정 아님. `PKCE-RFC7636-C3` 은 공식만 제공하고 해당 raw 의 Does-not-prove 가 "plain method 도 사용 가능"이라고 명시. RFC 원문의 "If the client is capable of using S256, it MUST use S256, as S256 is Mandatory To Implement (MTI) on the server" 문장이 발췌되지 않음 | RFC 7636 §4.2 를 `wiki-source-summarizer` 로 재발췌해 기존 `raw/official-docs/oauth2-pkce-rfc-7636.md` 에 claim(C6) 추가. **단 실행 주체는 본 hub 가 아니라 owner** [[raw/branch-notes/feature-keycloak-pkce-flow-stages]] D1 (Reference-Only — 본 hub 는 요약만 보유) | `needs-confirmation` |
|
||||
| Keycloak `Revoke Refresh Token: ON` + `Refresh Token Max Reuse: 0` 가 refresh rotation 을 정확히 한 번만 허용 | `OA21-C3` scope/resource binding 만 표준 — rotation 동작은 Keycloak 구현 결정 | refresh token 두 번 연속 사용 → 두 번째에서 4xx 응답 + access token 도 invalid 화 확인 | `planned` |
|
||||
| Spring Security 기본 `JwtDecoder` 의 JWKS 캐싱 TTL = 5분 (key rotation 시 자동 갱신) | `SSRS-JWT-C2` 는 startup 시 discovery 4단계 보장, 캐시 TTL 수치는 본 인용 범위 밖. owner [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D5 도 `UNSUPPORTED_DECISION` 으로 동일 판정 | Keycloak 키 회전 후 5분 이내 backend 가 새 key 로 검증 가능한지 확인 | `needs-confirmation` |
|
||||
| oauth2-proxy 대비 SPA Direct 의 token revocation 차이 (proxy session 종료 즉시 vs Keycloak refresh revoke + access token TTL 대기) | 표 비교 자체는 본 sub-branch 의 분석. 공식 비교는 없음 | 두 패턴 모두 구현 후 logout → 즉시 후속 API 호출의 401 발생 시점 비교 | `planned` |
|
||||
| `iss` claim 이 `KC_HOSTNAME` + realm path 로 결합되는 정확한 규칙 (D7 의 중심 명제 = **추론**) | `keycloak-hostname-configuration.md` 의 Usage Boundaries 가 "본 페이지에선 명시 없음 — Resource Server 측 검증 동작과 결합" 으로 못박음. D7 은 이 결합을 전제로 `iss` 고정을 주장 | ① `iss` ← `KC_HOSTNAME` 결합 자체는 **single-EC2 로 검증 가능** — P3A [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] D1 이 이미 소유(`KC_HOSTNAME=localhost` → `iss=http://localhost:8080/realms/...` 관찰). ② 그러나 **frontchannel/backchannel 분리 시 `/.well-known/openid-configuration` 의 `issuer` 가 어느 쪽으로 표시되는지**는 그 분리 토폴로지를 세워야만 확인 가능 — **D8/F5 가 세우지 않기로 한 바로 그 환경**이라 순환 유예 | `needs-confirmation` (**②는 D8/F5 에 의해 무기한 blocked** — cluster-internal 고유 함정이 문서로만 남는다는 D8 Open Risk 의 구체적 실례. F5 가 바뀌면 해제) |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- Keycloak Securing Apps 메인 URL(`/docs/latest/securing_apps/`)이 404. 대안 URL(`/securing-apps/overview`)로 fallback. 향후 구현 시 정확한 latest URL은 Keycloak release notes에서 재확인 필요.
|
||||
|
||||
## 묶음 (자식 sub-sub-branches)
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/company-tech-blogs/curity-bff-pattern-spa]]
|
||||
- [[raw/official-docs/keycloak-client-pkce-method-enforcement-official]]
|
||||
- [[raw/official-docs/keycloak-securing-apps-overview-official]]
|
||||
- [[raw/official-docs/oauth-v2-1-draft-ietf]]
|
||||
- [[raw/official-docs/oauth2-pkce-rfc-7636]]
|
||||
- [[raw/official-docs/owasp-html5-storage-xss-spa]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
- [[raw/branch-notes/feature-keycloak-pkce-flow-stages]]
|
||||
- [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]]
|
||||
- [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]]
|
||||
- [[raw/branch-notes/feature-keycloak-bff-vs-spa-direct]]
|
||||
- [[raw/branch-notes/feature-keycloak-refresh-token-rotation]]
|
||||
|
||||
> Sources는 하단 "외부 근거" 섹션. Errors/Interview/Lectures/Blog 는 현재 없음.
|
||||
|
||||
## 관련 sub-branch
|
||||
|
||||
- 상위: [[raw/branch-notes/feature-keycloak-patterns]]
|
||||
- 비교 대상: [[raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation]] (P2B — Internal + Google federation)
|
||||
- 비교 대상: [[raw/branch-notes/feature-keycloak-edge-forwardauth-no-google]] (P1A — Edge ForwardAuth vs SPA Direct)
|
||||
- 구현 대상: [[raw/branch-notes/feature-keycloak-single-ec2-no-google]] (P3A — Single EC2 vanilla JS 구현)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크: (미구현 — 문서까지만)
|
||||
- 머지 결과 / 배포 환경: 없음
|
||||
- **wiki 추출 대상**: 현 단계 없음. P2A는 `documented-only` 범위 — `wiki/concepts/`로의 추출은 다른 패턴들과 함께 비교 매트릭스가 완성된 뒤에만.
|
||||
- **추출하지 않을 항목**: P2A는 본 branch에서 구현 안 함. `actually-implemented`/`locally-verified` 등급의 자체 wiki/projects/ 문서는 생성 불가.
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
---
|
||||
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`.
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
---
|
||||
title: branch / feature-keycloak-nginx-auth-request-integration (P1A — nginx auth_request 통합)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-PATTERNS-OVERVIEW-013
|
||||
kind: project-work-item
|
||||
project: keycloak-patterns-overview
|
||||
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-013
|
||||
inherits: [DEC-KEYCLOAK-PATTERNS-OVERVIEW-EDGE-FORWARDAUTH-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-KEYCLOAK-PATTERNS-OVERVIEW-012]
|
||||
contract_packet: 1
|
||||
branch: feature-keycloak-nginx-auth-request-integration
|
||||
parent_branch:
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, p1a, nginx, auth-request, subrequest, cookie-limit]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: f798f591573547bd411e1edfe92d4c5c999d10c22903ac34e81c02b0f934581f
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-nginx-auth-request-integration (P1A — nginx auth_request 통합)
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/project-notes/keycloak-patterns-overview]] Work Item의 직접 자식 branch.
|
||||
> nginx의 `auth_request` directive와 oauth2-proxy `/oauth2/auth` endpoint contract를 **subrequest 응답 단위**로 분해. oauth2-proxy 자체 설정은 `-1-1`, 네트워크 격리는 `-1-3`에서 별도 다룬다.
|
||||
> 본 sub-sub-branch는 **문서까지만** (`documented-only`).
|
||||
> `status_label`: `in-progress`
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/project-notes/keycloak-patterns-overview]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: nginx auth_request 통합과 4KB cookie split case가 검증된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-EDGE-FORWARDAUTH-001@1` | AP4는 oauth2-proxy ForwardAuth를 사용한다 | nginx auth_request 통합과 4KB cookie split case 검증에 적용한다 | [[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 -->
|
||||
## 목표
|
||||
|
||||
P1A 패턴이 작동하는 **물리적 지점**은 nginx `auth_request` directive가 oauth2-proxy로 subrequest를 보내고, 응답 status(2xx/401)와 응답 헤더(`X-Auth-Request-*`)를 받아 backend로 propagate하는 그 한 줄에 모인다. 본 문서는 이 contract의 각 부품 — directive 위치, subrequest body 처리, `auth_request_set` 변수 추출, named location `@oauth2_signin` redirect, 그리고 **4kb cookie/header 한도 함정** — 을 분리해서 본다.
|
||||
|
||||
핵심 질문:
|
||||
1. `auth_request` directive는 어느 `server` / `location` block에 놓아야 하는가? 모든 backend `location`에 반복해야 하는가, 아니면 상속되는가?
|
||||
2. oauth2-proxy `/oauth2/auth` endpoint의 **응답 contract**는? 202 / 401 / 403의 의미와 nginx 측 처리.
|
||||
3. `X-Auth-Request-User` 같은 응답 헤더를 backend `proxy_pass`에 어떻게 전달하는가? (`auth_request_set` + `proxy_set_header`)
|
||||
4. access_token까지 cookie에 담으면 왜 nginx가 502를 내는가? → `proxy_buffer_size` / `large_client_header_buffers` / 4kb 한도와 multi-part cookie splitter.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
> 본 sub-sub 가 **결정을 소유하는** 범위. 각 항목은 `Decision Evidence Map` 의 D-ID 로 종결.
|
||||
>
|
||||
> ⚠️ **산출물의 지위**: 본 branch 의 nginx.conf 명세는 부모 D1 이 K8s+ingress-nginx 분기를 유지하는 한 **학습용 참조 구현이며 배포 대상이 아니다** (부모 D1 은 단일 VM docker-compose 를 Traefik 으로 보낸다) — §엣지·실패·의존 의 "다른 계약 의존" 첫 bullet 참조.
|
||||
|
||||
- nginx `auth_request` subrequest 응답 contract (2xx allow / 401 redirect / 403 deny) 의 nginx 측 처리 — D2
|
||||
- 인증 실패 응답의 route 별 분기 (browser-facing 302 vs API/machine plain 401) — D9
|
||||
- `auth_request_set` + `proxy_set_header` 2-step 헤더 propagation 메커니즘 — D3
|
||||
- backend 로 전달할 `X-Auth-Request-*` 헤더 목록 선정 — D4
|
||||
- 4kb cookie/header 한도 함정 인식 + cookie split 동작 + nginx buffer 튜닝 — D5
|
||||
- subrequest 의 원 요청 body 차단 (`proxy_pass_request_body off` + `Content-Length ""`) — D6
|
||||
- oauth2-proxy 자체 endpoint 의 nginx 라우팅 (`location /oauth2/` prefix vs `location = /oauth2/auth` exact 분리) — D7
|
||||
- access token 을 cookie/헤더로 전달할지의 분기 (학습 단계는 양쪽 다이어그램화) — D8
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외. 각 항목은 **다른 owner** 가 있거나 본 branch 단계(`documented-only`) 밖이다.
|
||||
|
||||
- **oauth2-proxy 자체 구성** (`--provider`, `--cookie-secret`, `--oidc-issuer-url`, OIDC code flow) → 형제 [[raw/branch-notes/feature-keycloak-oauth2-proxy-oidc-flow]]
|
||||
- **네트워크 격리 / header spoofing 방어** (NetworkPolicy, SG, mTLS, shared-secret 헤더) → 형제 [[raw/branch-notes/feature-keycloak-header-spoofing-defense]]. 본 branch 는 헤더를 *주입* 할 뿐, 그 헤더를 외부 위조로부터 지키는 것은 그쪽 결정 — D3 Open Risk 참조
|
||||
- **Traefik `forwardAuth` 비교** → 형제 [[raw/branch-notes/feature-keycloak-traefik-forwardauth-alternative]] (D1 이 본 branch 를 nginx 조합으로 한정)
|
||||
- **K8s ingress-nginx annotation 방식** (`nginx.ingress.kubernetes.io/auth-url`·`auth-signin`) — 2026-07-17 조사에서 공식 대안으로 확인됐으나 본 branch 는 standalone nginx config 를 다룬다. D7 의 선택 조건에 분기만 기록하고 명세는 남기지 않음 (`OUT_OF_BRANCH_SCOPE`)
|
||||
- **backend RS 의 access token audience validation** → [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] (D8 Open Risk 가 이 의존을 명시)
|
||||
- **실 구현 / 실측** — 본 sub-sub 는 `documented-only`. 모든 실측 항목은 `Claims To Verify` 로 분리되어 P3A 단계에서 수행
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 부모 sub-branch에서 인용한 외부 자료를 재참조 (추가 조사 없음).
|
||||
|
||||
- [[raw/official-docs/nginx-auth-request-module-official]] — ngx_http_auth_request_module (2xx=allow / 401|403=deny contract)
|
||||
- [[raw/official-docs/oauth2-proxy-nginx-integration-official]] — oauth2-proxy 공식의 nginx 통합 가이드
|
||||
- [[raw/official-docs/nginx-core-module-location-internal-official]] — D7: `internal` directive 동작(외부 요청 404) + `location` 매칭 우선순위(exact `=` > prefix longest-match) 메커니즘 근거
|
||||
- [[raw/official-docs/oauth2-proxy-endpoints-official]] — oauth2-proxy 자체 endpoint(`/oauth2/start`·`/oauth2/callback`·`/oauth2/sign_in`·`/oauth2/sign_out`·`/oauth2/userinfo`·`/oauth2/static/*`·`/oauth2/auth`) 별 용도와 호출 주체 근거 (D7)
|
||||
- [[raw/official-docs/proxy-pass-request-body-nginx-official]] — `proxy_pass_request_body` directive 의 Default(`on`)/Description/공식 예제 (D6 메커니즘 근거)
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기.
|
||||
|
||||
- [ ] nginx config `location = /oauth2/auth` block 작성 (internal, `proxy_pass http://oauth2-proxy.upstream`, `proxy_pass_request_body off`, `proxy_set_header Content-Length ""`) — 등급: `planned`
|
||||
- [ ] `auth_request /oauth2/auth;` directive를 protect할 `location /api/` 등 backend block에 추가 — 등급: `planned`
|
||||
- [ ] subrequest 응답 status별 nginx 처리: 2xx=allow / 401=`error_page 401 = @oauth2_signin` / 403=deny — 등급: `planned`
|
||||
- [ ] named location `@oauth2_signin` 작성: `return 302 https://$host/oauth2/start?rd=$scheme://$host$request_uri;` — 등급: `planned`
|
||||
- [ ] 응답 헤더 propagation: `auth_request_set $user $upstream_http_x_auth_request_user;` + `proxy_set_header X-User $user;` — 등급: `planned`
|
||||
- [ ] 전달할 헤더 목록 정리: `X-Auth-Request-User`, `X-Auth-Request-Email`, `X-Auth-Request-Groups`, (옵션) `X-Auth-Request-Access-Token` — 등급: `planned`
|
||||
- [ ] **4kb cookie 한도 함정**: access_token cookie 포함 시 nginx 기본 `proxy_buffer_size 4k` / `large_client_header_buffers` 초과 → 502/400 발생. 해결: oauth2-proxy `--cookie-secret` + cookie 분할 (`_oauth2_proxy_0`, `_1`, …) 동작 이해 — 등급: `planned`
|
||||
- [ ] nginx 측 튜닝 옵션 정리: `proxy_buffer_size 16k; proxy_buffers 4 16k; large_client_header_buffers 4 16k;` — 등급: `planned`
|
||||
- [ ] `/oauth2/callback`, `/oauth2/start`, `/oauth2/sign_out` 등 oauth2-proxy 자체 endpoint들이 nginx를 통과하도록 `location /oauth2/` block 작성 — 등급: `planned`
|
||||
- [ ] subrequest의 원래 request body가 oauth2-proxy로 전달되지 않도록 `proxy_pass_request_body off` 강제 + `Content-Length` 빈 값 처리 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
> 작업하며 떠오른 메모.
|
||||
|
||||
- nginx `auth_request` core는 별도의 authorization subrequest를 만든다. 다만 실제 `location = /oauth2/auth`가 `proxy_pass`로 oauth2-proxy에 전달될 때는 proxy module의 기본값이 `proxy_pass_request_body on`이므로 원 요청 body가 upstream으로 전달될 수 있다. `/oauth2/auth`는 헤더·쿠키만 검사하므로 실행 설정에서 `proxy_pass_request_body off`와 빈 `Content-Length`를 함께 명시한다.
|
||||
- `auth_request_set`은 subrequest **응답 헤더**에서 값을 빼와서 nginx 변수에 담는 단계. 이걸 빠뜨리면 backend는 그냥 unauthenticated 요청을 받는다.
|
||||
- 4kb 한도 함정은 P1A 패턴에서 가장 흔한 502 원인. 면접 질문 후보: "edge ForwardAuth 운영 중 backend가 갑자기 502 내기 시작하면 어디부터 보겠나?"
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- **2026-05-25**: 본 sub-sub는 **nginx + oauth2-proxy 조합**에 한정. Traefik의 `forwardAuth` middleware는 `-1-4`에서 별도 비교.
|
||||
- **2026-05-25 (decision candidate)**: access_token을 cookie에 담을지(backend에서 토큰 필요) 헤더 noise로만 식별자만 넘길지는 backend 요구에 따라 갈림. 학습 단계에서는 둘 다 다이어그램화.
|
||||
- **2026-07-17**: 인증 실패 응답을 **browser-facing route(302 redirect) 와 API/machine route(plain 401 pass-through) 로 분리** (→ D9). / 이유: 기존 D2 의 Open Risk("XHR 에 302 는 부적절")를 닫는 답을 공식 문서에서 확보. / 검토한 대안: 전 route 일괄 302(= 기존 D2 단독) — API client 가 로그인 HTML 을 받게 되어 기각. / 근거: `[[raw/official-docs/oauth2-proxy-nginx-integration-official]]#O2PN-C9` (§Browser vs API Routes).
|
||||
- **2026-07-17**: D6(subrequest body 차단)·D7(oauth2-proxy endpoint 라우팅)의 `UNSUPPORTED_DECISION` 라벨 **해소**. / 근거: `#O2PN-C7`·`#O2PN-C8`(공식 nginx.conf 4-block 예제 — 기존 raw 가 산문 섹션만 인용하고 예제 블록 자체를 놓치고 있었음), `#NGAR-C8`(nginx.org 벤더-중립 Example Configuration — oauth2-proxy 와 독립된 2번째 공식 출처), `[[raw/official-docs/proxy-pass-request-body-nginx-official]]#NGXPM-C1`(default `on` 시맨틱), `[[raw/official-docs/oauth2-proxy-endpoints-official]]#O2EP-C1`~`C8`(endpoint 별 용도), `[[raw/official-docs/nginx-core-module-location-internal-official]]#NGCM-C1`·`#NGCM-C2`(`internal` 동작 + exact>prefix 매칭). / **단, `/oauth2/auth` 에 `internal;` 을 붙이는 하드닝은 공식 예제에 없으므로 `UNSUPPORTED_IMPL_DECISION` 으로 잔존** (§구현 가이드 §1).
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. TODO 의 nginx config 패턴 (subrequest contract / `auth_request_set` / 4kb cookie 한도 / 401 redirect) 은 모두 공식 nginx 모듈 + oauth2-proxy nginx 통합 페이지에서 직접 뒷받침됨.
|
||||
>
|
||||
> **2026-07-17 갱신**: D6·D7 의 `UNSUPPORTED_DECISION` 라벨 해소(공식 nginx.conf 예제 블록 + endpoint 목록 + nginx core module 근거 확보), D9 신설(browser vs API route 분리). `선택 조건` 열 추가. **남은 `UNSUPPORTED_DECISION` 은 D1 하나뿐이며, 이는 학습 범위 분할이라는 조직적 결정이라 vendor doc 인용 대상이 아니다.** 근거 없는 *구현* detail 은 §구현 가이드의 `UNSUPPORTED_IMPL_DECISION` 4건(`internal;` 부착 / upstream 주소 / 버퍼 수치 / browser-API 판별 기준 + `@oauth2_signin` 목적지)으로 분리했다.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | 본 sub-sub 는 nginx + oauth2-proxy 조합으로 한정 (Traefik forwardAuth 는 sibling sub-sub `-1-4` 에서 별도 비교) | **부모 D1 의 스택 선택 기준을 상속**: K8s + ingress-nginx 환경 → oauth2-proxy(본 노트) / 단일 VM docker-compose → Traefik forwardAuth(sibling `-1-4`). 즉 본 노트의 config 는 *전자* 를 가정한다 — [[raw/branch-notes/feature-keycloak-edge-forwardauth-no-google]] `D1` 참조. ⚠️ 본 branch 가 조사한 공식 예제는 **standalone nginx** 기준이라 부모 D1 의 분기와 긴장 관계 — §엣지·실패·의존 참조 | UNSUPPORTED_DECISION (학습 분할 결정 — vendor doc 인용 불요) | N/A (organizational decision) | nginx config 만 보면 Traefik 와의 비교 매트릭스 작성 시 누락된 옵션 (`authResponseHeaders` 등) 인식 지연 |
|
||||
| D2 | `/oauth2/auth` subrequest 응답 contract 채택: 2xx → allow, 401 → `error_page 401 = @oauth2_signin` redirect, 403 → deny | **route 유형이 분기 기준**: browser-facing route(사람이 브라우저로 여는 페이지) → 본 결정대로 401 을 `@oauth2_signin` 302 redirect 로 변환. **API/machine route → 302 로 변환하지 않고 plain 401 pass-through (D9)**. 403 은 양쪽 공통 deny(재로그인해도 해소 안 되는 인가 실패이므로 redirect 무의미). 2xx 변종(200/202/204)은 모두 allow 로 동일 취급(`NGAR-C2` does-not-prove 상 변종별 차이 없음) | `raw/official-docs/nginx-auth-request-module-official.md#NGAR-C2` (nginx 응답 코드 contract), `raw/official-docs/oauth2-proxy-nginx-integration-official.md#O2PN-C2` (`/oauth2/auth` 202/401 spec), `raw/official-docs/oauth2-proxy-nginx-integration-official.md#O2PN-C5` (401 → 302 redirect 패턴) | `official-vendor-doc` (nginx F5 공식 + oauth2-proxy 공식 양측 verbatim 확인) | XHR/API 요청에 302 redirect 반환이 적절하지 않음 (`O2PN-C5` does-not-prove) — API client 별도 처리 필요 |
|
||||
| D3 | 응답 헤더 propagation 메커니즘 채택: `auth_request_set $user $upstream_http_x_auth_request_user` + `proxy_set_header X-User $user` 2-step | **backend 가 사용자 신원을 필요로 하는가** 가 분기 기준: 필요 → 2-step 전개(+ oauth2-proxy 를 `--set-xauthrequest` 로 실행해야 응답 헤더가 나옴, `O2PN-C3`). 불필요(단순 인증 게이팅만) → `auth_request` 만 두고 `auth_request_set`/`proxy_set_header` 생략 — 이 경우 backend 는 "누구인지" 모른 채 "인증됨" 만 보장받음. 대안(oauth2-proxy 를 reverse-proxy 모드로 두고 `--pass-user-headers` 사용, `OAUTH2PROXY-C4`)은 edge ForwardAuth 패턴이 아니므로 본 branch 범위 밖 | `raw/official-docs/nginx-auth-request-module-official.md#NGAR-C5` (`auth_request_set` + `$upstream_http_*` 일반 메커니즘), `raw/official-docs/oauth2-proxy-nginx-integration-official.md#O2PN-C3` (X-User/X-Email 헤더 매핑) | `official-vendor-doc` | backend 가 `X-User` 헤더를 신뢰해도 안전한 보안 전제는 미증명 (`O2PN-C3` does-not-prove) — header spoofing 방어 (sub-sub `-1-3`) 필요 |
|
||||
| D4 | 전달 헤더 목록: `X-Auth-Request-User`, `X-Auth-Request-Email`, `X-Auth-Request-Groups`, (옵션) `X-Auth-Request-Access-Token` | **최소 전달 원칙 — backend 가 실제로 쓰는 claim 만**: 식별자만 필요 → `User`(+`Email`) 만. role/group 기반 authz → `Groups` 추가. backend 가 토큰 자체를 필요 → `Access-Token` 추가(단 이는 D8 이 소유하는 분기이며 `--pass-access-token` 선행 필요). 헤더 이름 규약은 본 branch 가 아니라 **부모 D2 가 owner** (nginx 계열 `X-Auth-Request-*` 우선) — 부모가 규약을 바꾸면 본 행도 따라감 | `raw/official-docs/oauth2-proxy-overview-config-official.md#OAUTH2PROXY-C3` (4종 X-Auth-Request-* 응답 헤더), `raw/official-docs/oauth2-proxy-overview-config-official.md#OAUTH2PROXY-C2` (`--pass-access-token` → `X-Forwarded-Access-Token` / `X-Auth-Request-Access-Token`), `raw/official-docs/oauth2-proxy-nginx-integration-official.md#O2PN-C4` (`--pass-access-token` + access token forwarding 패턴) | `official-vendor-doc` | `X-Auth-Request-Preferred-Username` 도 spec 에 존재 (`OAUTH2PROXY-C3`) — 학습 시 추가 정리 필요. 또한 access token forwarding 이 RS audience validation 을 대체 안 함 (`O2PN-C4` does-not-prove) |
|
||||
| D5 | 4kb cookie 한도 함정 인식 + cookie split 대응 (`_oauth2_proxy_0`, `_1`, …) + nginx buffer 튜닝 (`proxy_buffer_size 16k; proxy_buffers 4 16k; large_client_header_buffers 4 16k`) | **세션 cookie 가 4KB 를 넘는가** 가 분기 기준이며, 이는 **D8 의 선택에 종속**: D8-(a) access token 을 세션에 포함 → 4KB 초과 가능성 높음(특히 Keycloak 의 realm/client role claim 이 많을 때) → 버퍼 튜닝 + split cookie 대응 **필수**. D8-(b) 식별자만 전달 → 4KB 여유 → 기본 버퍼로 충분하나, claim 이 늘면 재검토. **한도 자체(4KB)만 공식이고 튜닝 수치(16k)는 사용자 임의** — §구현 가이드 §4 의 `UNSUPPORTED_IMPL_DECISION` 참조 | `raw/official-docs/oauth2-proxy-nginx-integration-official.md#O2PN-C6` (4KB 한도 + cookie split + nginx 의 first Set-Cookie 만 복사 한계) | `official-vendor-doc` (oauth2-proxy 공식이 4KB 한계와 split 동작 명시) | nginx 가 multi-part Set-Cookie 를 모두 복사하도록 하는 정확한 lua/scripting 방식은 인용 범위 밖 (`O2PN-C6` does-not-prove) — 실 적용 시 lua 스크립트 작성 필요 |
|
||||
| D6 | `proxy_pass_request_body off` + `Content-Length ""` 로 subrequest body 전달 차단 | **auth_request 목적지가 body 를 읽는가** 가 분기 기준: `/oauth2/auth` 처럼 헤더/쿠키만 보고 202/401 을 내는 인증 체크 endpoint(`O2PN-C2`) → 본 결정대로 `off`. 목적지가 body 를 실제로 검사해야 하는 커스텀 인증 서비스(예: request-signing 검증)라면 → `off` 하면 인증이 깨지므로 default(`on`) 유지 — 단 그 구성은 본 branch 범위 밖(oauth2-proxy 전용 조사). 제3 옵션 `proxy_request_buffering` 은 *전달 여부* 가 아니라 *버퍼링 방식* 을 제어하므로 본 분기와 무관(2026-07-17 조사에서 탐색 후 기각) | `raw/official-docs/nginx-auth-request-module-official.md#NGAR-C8` (nginx 공식 "Example Configuration" 이 `/auth` subrequest 목적지 location 에서 이 두 directive 를 예제로 명시), `raw/official-docs/proxy-pass-request-body-nginx-official.md#NGXPM-C1` (`proxy_pass_request_body` 의 Default 는 `on` — 명시적 `off` 없이는 원본 body 가 그대로 proxied server 로 전달된다는 directive 자체의 메커니즘), `raw/official-docs/proxy-pass-request-body-nginx-official.md#NGXPM-C2` (`ngx_http_proxy_module` 자체 공식 문서도 `off` + `Content-Length ""` 조합을 예제로 제시 — X-Accel-Redirect 맥락이지만 `NGAR-C8` 과 독립된 2번째 공식 출처) | `official-vendor-doc` (nginx 모듈 설계자 자신의 vendor-neutral 예제 2건 — `ngx_http_auth_request_module`(`NGAR-C8`) + `ngx_http_proxy_module`(`NGXPM-C1`/`C2`) 양쪽에서 독립 확인됨. **directive 메커니즘(default on/off 시맨틱) 자체는 이제 2중 확인**) | `NGXPM-C1`/`C2` 는 auth_request subrequest 가 반드시 `proxy_pass` 기반 location 으로 라우팅된다는 것을 증명하지 않으며, 이 조합이 auth_request 서브리퀘스트에 대해 "공식적으로 필수"임을 증명하지도 않는다 (그 전용 권고는 `NGAR-C8` 담당, `NGXPM-C2` 는 X-Accel-Redirect 예제일 뿐). 설정을 **없을 때 정확히 어떤 에러가 발생하는지도 여전히 증명하지 않음** (원문들은 권장/기본값 설명만 제시, 실패 모드 기술 없음) — subrequest 가 body 를 가지고 가면 POST endpoint 가 의도치 않게 트리거되거나 oauth2-proxy CPU 증가 가능하다는 추론은 여전히 `needs-confirmation` |
|
||||
| D7 | `/oauth2/callback`, `/oauth2/start`, `/oauth2/sign_out` 등 oauth2-proxy 자체 endpoint 들이 nginx 를 통과하도록 `location /oauth2/` block 작성 (동시에 `/oauth2/auth` 만 별도 `location = /oauth2/auth` exact block 으로 분리 가능한지는 nginx 매칭 메커니즘에 달림) | **배포 형태가 분기 기준**: 단일 도메인 + standalone nginx → 본 결정(`location /oauth2/` prefix + `location = /oauth2/auth` exact, 공식 1차 예제 `O2PN-C7`). K8s + ingress-nginx → nginx.conf 대신 annotation(`auth-url`/`auth-signin`) 방식 — 본 branch 범위 밖(`OUT_OF_BRANCH_SCOPE`, §범위 참조). 다중 앱 도메인 SSO → 도메인마다 prefix block 반복(공식 예제 주석의 `X-Auth-Request-Redirect $scheme://$host$request_uri` 가 이 변형을 시사). **oauth2-proxy 를 별도 subdomain 에 중앙 배치하는 안은 2026-07-17 조사에서 공식 근거 부족으로 기각** (cross-domain cookie 설계가 전부 미증명 추론 영역) | `raw/official-docs/oauth2-proxy-nginx-integration-official.md#O2PN-C7` (**1차 근거** — 공식 nginx.conf 예제가 `location /oauth2/` prefix block 과 `location = /oauth2/auth` exact block 을 실제로 2-block 으로 제시), `raw/official-docs/oauth2-proxy-endpoints-official.md#O2EP-C1` (`/oauth2/start` = OAuth cycle 시작 redirect URL), `#O2EP-C2` (`/oauth2/callback` = IdP 가 설정하는 callback url), `#O2EP-C3` (`/oauth2/sign_in`), `#O2EP-C4` (`/oauth2/sign_out`), `#O2EP-C5` (`/oauth2/userinfo`), `#O2EP-C6` (`/oauth2/static/*`), `#O2EP-C7` (`/oauth2/auth` 만 별도로 "for use with the Nginx auth_request directive" 라벨 — 나머지 6개 endpoint 는 이 라벨이 없음), `#O2EP-C8` (전체 목록이 `/oauth2` prefix 를 공유하며 `--proxy-prefix` 로 변경 가능), `raw/official-docs/nginx-core-module-location-internal-official.md#NGCM-C1` (`internal;` 직접 정의 + `auth_request` 서브리퀘스트가 공식 "internal request" 트리거 목록에 포함됨을 확인), `raw/official-docs/nginx-core-module-location-internal-official.md#NGCM-C2` (`location` 매칭에서 `=` exact match 가 발견되면 즉시 검색 종료 — prefix `location /oauth2/` 과 exact `location = /oauth2/auth` 가 같은 `/oauth2` 네임스페이스 아래 충돌 없이 공존 가능한 nginx 엔진 메커니즘) | `official-vendor-doc` (oauth2-proxy 공식 nginx.conf 예제 + endpoint 목록 + nginx F5 공식 core module 3중 verbatim 확인). **prefix/exact 2-block 분리 자체는 `O2PN-C7` 공식 예제로 직접 근거 있음** — 공식이 실제로 그렇게 config 를 제시한다. 미증명인 것은 오직 **`/oauth2/auth` 에 `internal;` 을 붙이는 하드닝 처방** 뿐이며(공식 예제엔 `internal` 문자열 자체가 없음), 이는 `/oauth2/auth` 만 auth_request 전용 라벨이 있다는 **비대칭**(`O2EP-C7`) + exact>prefix 매칭 규칙(`NGCM-C2`) + auth_request 가 internal 트리거 목록에 포함(`NGCM-C1`)을 결합한 사용자 추론이다 | `/oauth2/auth` 에 `internal;` 을 붙여도 안전한지는 공식 문서 미진술 (`O2EP-C7` does-not-prove) — `NGCM-C1`+`NGCM-C2` 는 그런 구성이 nginx 엔진 차원에서 **기술적으로 가능**하다는 메커니즘만 증명하며, oauth2-proxy 공식이 그렇게 **권고**한다는 것은 증명하지 않는다 (oauth2-proxy 공식 nginx 통합 예제 자체는 `internal;` 미사용 — negative finding, `NGCM-C2` Usage Boundaries 참조). nginx 측 `location = /oauth2/auth { internal; ... }` 격리와 `location /oauth2/ { ... }` 공개 block 을 분리하는 실제 구성은 여전히 사용자 추론 영역이며, `oauth2-proxy-nginx-integration-official.md#O2PN-C5` (401→302 redirect) 와 결합해도 나머지 5개 endpoint(`start`/`sign_in`/`userinfo`/`static`) 의 명시적 라우팅 예제는 없음 |
|
||||
| D8 | access token 을 cookie 에 담을지 헤더로만 식별자 넘길지는 backend 요구에 따라 갈림 (학습 단계는 둘 다 다이어그램화) | **backend 가 토큰 자체를 필요로 하는가** 가 분기 기준: (a) backend 가 RS 로서 토큰을 검증하거나 그 토큰으로 다운스트림 API 를 호출해야 함 → `--pass-access-token` + `auth_request_set $token $upstream_http_x_auth_request_access_token`. **대가: 세션이 4KB 를 넘겨 D5 의 버퍼 튜닝·split cookie 대응이 필수가 됨.** (b) backend 가 "누구인지" 만 필요 → 식별자 헤더만 전달(D4), 토큰 미전달 → 세션이 작아 D5 부담 없음. 학습 단계에선 **결정을 확정하지 않고 양쪽을 다이어그램화** (실 채택은 P3A) | `raw/official-docs/oauth2-proxy-overview-config-official.md#OAUTH2PROXY-C2`, `raw/official-docs/oauth2-proxy-nginx-integration-official.md#O2PN-C4` (둘 다 `--pass-access-token` 옵션의 존재만 증명) | `official-vendor-doc` (옵션 존재) + 학습용 분기 결정 | backend RS 에서 access token audience validation 을 별도 수행해야 함 (`O2PN-C4` does-not-prove) — RS 측 검증 누락 시 P1A 무력화. (a) 선택 시 D5 의 4KB 함정이 *가능성* 이 아니라 *확정 과제* 로 전환됨 |
|
||||
| D9 | 인증 실패 응답을 **route 유형별로 분리**: browser-facing route → 401 을 `@oauth2_signin` 302 redirect 로 변환(D2), API/machine route → `error_page 401 =401` 로 **plain 401 을 그대로 pass-through** (redirect 금지) | **요청 주체가 사람의 브라우저인가 기계인가**: 사람이 브라우저로 여는 페이지 route → 302(로그인 화면으로 유도해야 UX 성립). SPA 의 XHR/fetch·CLI·서버간 호출 등 machine client route(예: `location /api/`) → plain 401/403(redirect 를 따라가면 로그인 HTML 을 JSON 대신 받게 되거나 CORS 로 실패). **한 서버에 두 유형이 공존하면 location 단위로 분리** — 본 결정이 D2 의 "XHR 에 302 는 부적절" Open Risk 를 닫는 답 | `raw/official-docs/oauth2-proxy-nginx-integration-official.md#O2PN-C9` (§Browser vs API Routes — "Redirecting authentication failures (302 to `/oauth2/sign_in`) should **only be used for browser-facing routes**. API or machine clients should receive a plain 401/403 response without redirect." + `location /api/ { auth_request /oauth2/auth; error_page 401 =401; proxy_pass http://backend/; }` 예제), `raw/official-docs/oauth2-proxy-nginx-integration-official.md#O2PN-C5` (browser 측 302 패턴 — 본 결정이 분리해낸 반대편) | `official-vendor-doc` (2026-07-17 조사로 확보 — 렌더링 HTML + GitHub raw markdown 2중 fetch 로 실존 확인, 이전 조사의 불일치는 재현되지 않음) | 공식은 **권고(should)** 일 뿐 강제 규범이 아님 (`O2PN-C9` does-not-prove). 또한 브라우저 `fetch` 가 302 를 실제로 어떻게 follow 하는지(그리고 CORS preflight 영향)는 원문 범위 밖 — `Claims To Verify` 로 실측 이관. route 를 browser/API 로 **어떤 기준으로 나눌지**(path prefix? `Accept` 헤더? `X-Requested-With`?)는 공식 미제시 — §구현 가이드 §3 의 `UNSUPPORTED_IMPL_DECISION` 참조 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 본 sub-sub 는 `documented-only` — 여기서 "구현"은 **nginx.conf 를 되묻지 않고 작성할 수 있는 수준의 사전 명세**를 뜻한다. 각 block 을 결정(D2~D9) + 근거 Claim ID 로 trace 하고, 공식 예제가 *말하지 않는* 선택은 `UNSUPPORTED_IMPL_DECISION` + trade-off 한 줄로 분리한다(CLAUDE.md §15.5 R2).
|
||||
>
|
||||
> **OUT_OF_BRANCH_SCOPE 정제(R3)**: oauth2-proxy 자체 flag(`--set-xauthrequest`·`--pass-access-token`·`--cookie-secret`)의 *값과 구성* 은 형제 [[raw/branch-notes/feature-keycloak-oauth2-proxy-oidc-flow]] 소유 — 본 §에는 "이 flag 가 켜져 있어야 이 nginx 설정이 성립한다"는 **전제** 로만 등장하고 명세는 남기지 않는다. 네트워크 격리·헤더 위조 방어는 형제 [[raw/branch-notes/feature-keycloak-header-spoofing-defense]] 소유. K8s ingress-nginx annotation 방식은 §범위 Out of scope.
|
||||
|
||||
### 0. nginx.conf 4-block 골격 (전체 구조)
|
||||
|
||||
> **Trace**: 공식 1차 예제 `oauth2-proxy-nginx-integration-official#O2PN-C7` (prefix/exact 2-block 분리) + `#O2PN-C9` (API route 4번째 block) + `nginx-core-module-location-internal-official#NGCM-C2` (exact `=` 가 prefix 를 이기는 매칭 규칙 — 이 골격이 성립하는 nginx 엔진 근거).
|
||||
|
||||
| # | block | 위상 | 소유 결정 |
|
||||
|---|---|---|---|
|
||||
| 1 | `location /oauth2/` | **public** — 브라우저가 직접 도달 (start·callback·sign_in·sign_out·userinfo·static) | D7 |
|
||||
| 2 | `location = /oauth2/auth` | **subrequest 전용** — exact match 라 1번 prefix 에 가로채이지 않음 | D6, D7 |
|
||||
| 3 | `location /` | 보호 대상 **browser-facing** route → 401 을 302 로 | D2, D3, D4 |
|
||||
| 4 | `location @oauth2_signin` | named location — 3번의 `error_page 401` 목적지 | D2 |
|
||||
| 5 | `location /api/` | 보호 대상 **API/machine** route → 401 을 그대로 | D9 |
|
||||
|
||||
**핵심 메커니즘**: 1번과 2번은 같은 `/oauth2` 문자열을 공유하지만 **충돌하지 않는다** — `NGCM-C2` 가 증명하듯 `=` exact match 가 발견되면 nginx 는 검색을 즉시 종료하므로 `/oauth2/auth` 요청은 항상 2번으로 간다. **2번 block 을 지우면 `/oauth2/auth` 가 1번 prefix 로 매칭되어 D6 의 body 차단이 조용히 사라진다** (§엣지 참조).
|
||||
|
||||
### 1. `location = /oauth2/auth` — subrequest 목적지 (D6, D7)
|
||||
|
||||
> **Trace**: D6/D7 / `O2PN-C7`(공식 예제의 exact block), `O2PN-C8`(`Content-Length ""` + `proxy_pass_request_body off` + 인라인 주석), `NGAR-C8`(nginx.org 벤더-중립 Example Configuration 이 동일 패턴), `NGXPM-C1`(`proxy_pass_request_body` default 는 `on` — 명시 안 하면 body 가 전달됨), `O2PN-C2`(`/oauth2/auth` 는 202/401 만 반환).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (a) **`internal;` 부착 여부** — 공식 예제에는 `internal` 문자열이 아예 없다(`O2PN-C7` negative finding). `NGCM-C1` 이 "`auth_request` 는 공식 internal-request 트리거 목록에 포함" 을 증명하므로 **기술적으로는 안전하게 부착 가능**하나, *공식이 권고한다* 고 쓰면 과장이다. trade-off: 부착하면 외부 client 가 `/oauth2/auth` 를 직접 호출해 세션 유효성만 떠보는 표면이 사라지지만, `/oauth2/auth` 를 폴링하는 모니터링이 있다면 깨진다(공식이 그런 용도를 언급하지 않아 반증·확증 모두 불가 — `needs-confirmation`). **학습 단계 권고: 부착하지 않고 공식 예제를 그대로 재현 → P3A 에서 하드닝 선택.** (b) **upstream 주소** — 공식 예제는 `http://127.0.0.1:4180`. docker-compose 스택이면 service 명(`http://oauth2-proxy:4180`)이 되어야 하나 이는 배포 형태 의존이며 공식 미제시.
|
||||
|
||||
| directive | 값 | 근거 / 사유 |
|
||||
|---|---|---|
|
||||
| `proxy_pass` | `http://127.0.0.1:4180` (공식 예제 값) | `O2PN-C7`. 배포 형태에 따라 service 명으로 교체 — `UNSUPPORTED_IMPL_DECISION` (b) |
|
||||
| `proxy_set_header Host` | `$host` | `O2PN-C7` |
|
||||
| `proxy_set_header X-Real-IP` | `$remote_addr` | `O2PN-C7` |
|
||||
| `proxy_set_header X-Forwarded-Uri` | `$request_uri` | `O2PN-C7` |
|
||||
| `proxy_set_header Content-Length` | `""` | `O2PN-C8`, `NGAR-C8` — body 차단의 짝 |
|
||||
| `proxy_pass_request_body` | `off` | `O2PN-C8`, `NGAR-C8`. **생략하면 default `on`(`NGXPM-C1`) 이라 body 가 전달됨** |
|
||||
| `internal` | (미부착 — 학습 단계) | `UNSUPPORTED_IMPL_DECISION` (a) |
|
||||
|
||||
> ⚠️ **인용 경계**: 공식 주석 `# nginx auth_request includes headers but not body` 는 auth_request 의 **설계 사실** 을 말할 뿐, "body 를 넘기면 POST 가 오발동하거나 CPU 가 오른다"는 **인과** 를 말하지 않는다(`O2PN-C8` does-not-prove). 그 인과는 본 노트의 추론이며 §Claims To Verify 로 분리했다 — D6 의 근거로 재진술 금지.
|
||||
|
||||
### 2. `location /oauth2/` — oauth2-proxy 공개 endpoint (D7)
|
||||
|
||||
> **Trace**: D7 / `O2PN-C7`(공식 예제의 prefix block + `X-Auth-Request-Redirect` 헤더), `oauth2-proxy-endpoints-official#O2EP-C1`~`C6`(각 endpoint 의 용도), `#O2EP-C7`(`/oauth2/auth` 만 "for use with the Nginx auth_request directive" 라벨 — 나머지 6개엔 그 제약 없음), `#O2EP-C8`(전체가 `/oauth2` prefix 공유, `--proxy-prefix` 로 변경 가능).
|
||||
|
||||
| endpoint | 이 block 으로 노출되는 이유 | 근거 |
|
||||
|---|---|---|
|
||||
| `/oauth2/start` | OAuth cycle 을 시작하는 redirect URL — 브라우저가 진입 | `O2EP-C1` |
|
||||
| `/oauth2/callback` | IdP 가 **브라우저를 이 URL 로 되돌린다** — 외부 도달 불가면 로그인 자체가 완결 불가 | `O2EP-C2` |
|
||||
| `/oauth2/sign_in` | 로그인 페이지(겸 cookie 제거) | `O2EP-C3` |
|
||||
| `/oauth2/sign_out` | 세션 cookie 제거 | `O2EP-C4` |
|
||||
| `/oauth2/userinfo` | 세션의 email 을 JSON 으로 반환 | `O2EP-C5` |
|
||||
| `/oauth2/static/*` | sign_in/error 페이지의 stylesheet 등 | `O2EP-C6` |
|
||||
|
||||
`proxy_set_header X-Auth-Request-Redirect $request_uri;` 를 포함(`O2PN-C7`). 다중 도메인이면 공식 예제 주석대로 `$scheme://$host$request_uri` 로 확장(D7 선택 조건).
|
||||
|
||||
> ⚠️ **경계**: 위 6개가 "public 이어야 한다"는 **처방** 은 공식 문장이 아니다. 공식은 각 endpoint 가 *무엇을 하는지* 만 말한다(`O2EP-C1`~`C6` does-not-prove: 호출 주체). "그러므로 브라우저가 도달해야 한다"는 결론은 `/oauth2/callback` 의 "the oauth app will be configured with this as the callback url"(`O2EP-C2`) 에서만 강하게 함의되고, 나머지는 **본 노트의 추론**이다.
|
||||
|
||||
### 3. 보호 대상 route — browser vs API 분리 (D2, D9, D3, D4)
|
||||
|
||||
> **Trace**: D2/`O2PN-C5`(401 → `error_page` → 302 redirect), `NGAR-C2`(2xx allow / 401·403 deny contract) · D9/`O2PN-C9`(§Browser vs API Routes + `error_page 401 =401` 예제) · D3/`NGAR-C5`(`auth_request_set` + `$upstream_http_*`), `O2PN-C3`(`X-User`/`X-Email` 매핑, `--set-xauthrequest` 전제) · D4/`OAUTH2PROXY-C3`(4종 `X-Auth-Request-*` 응답 헤더).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (a) **browser/API 판별 기준** — 공식 예제는 `location /api/` 라는 **path prefix** 로 나눈다(`O2PN-C9`). 그러나 실제 앱이 path 로 깔끔히 갈리지 않으면(같은 path 에 HTML/JSON 혼재) `Accept` 헤더나 `X-Requested-With` 기반 분기가 필요한데 **공식은 이를 제시하지 않는다**. trade-off: path prefix 는 단순·명시적이나 앱 구조를 강제한다. 학습 단계는 공식대로 path prefix 채택. (b) **`@oauth2_signin` 의 목적지가 `/oauth2/start` vs `/oauth2/sign_in`** — 아래 별도 표 참조. (c) **backend 로 넘길 헤더 이름**(`X-User`) — 공식 예제 값이나, 본 프로젝트의 헤더 명명 규약 owner 는 **부모 D2**(`X-Auth-Request-*` 우선)이므로 부모 규약과 충돌 시 부모가 이긴다.
|
||||
|
||||
| route 유형 | `auth_request` | 401 처리 | 근거 |
|
||||
|---|---|---|---|
|
||||
| browser-facing (`location /`) | `auth_request /oauth2/auth;` | `error_page 401 = @oauth2_signin;` → 302 | D2, `O2PN-C5` |
|
||||
| API/machine (`location /api/`) | `auth_request /oauth2/auth;` | `error_page 401 =401;` → **plain 401 pass-through** | D9, `O2PN-C9` |
|
||||
|
||||
> ⚠️ **오타 아님**: 두 `error_page` 의 `=` 형태 차이(`= @oauth2_signin` 의 space + `=` vs `=401` 의 붙임)는 **의도적**이며 각각 공식 예제 verbatim 이다(`O2PN-C5` / `O2PN-C9`). nginx `error_page` 에서 `= @named` 는 named location 이 정한 코드를 따르고, `=401` 은 응답 코드를 401 로 **강제**한다 — 문법이 낯설다고 임의로 통일하면 D9 가 깨진다. (`error_page` 의 `=` 시맨틱 자체를 증명하는 claim 은 아직 raw 에 없음 — 필요 시 `nginx-core-module-location-internal-official` 에 증설)
|
||||
|
||||
헤더 propagation 2-step (browser route 기준, D3/D4):
|
||||
|
||||
| 단계 | directive | 근거 |
|
||||
|---|---|---|
|
||||
| 1. subrequest 응답 헤더 → nginx 변수 | `auth_request_set $user $upstream_http_x_auth_request_user;`<br>`auth_request_set $email $upstream_http_x_auth_request_email;` | `NGAR-C5`, `O2PN-C3` |
|
||||
| 2. 변수 → backend 요청 헤더 | `proxy_set_header X-User $user;`<br>`proxy_set_header X-Email $email;` | `O2PN-C3` |
|
||||
| (D8-a 선택 시) 토큰 | `auth_request_set $token $upstream_http_x_auth_request_access_token;` | `O2PN-C4` — `--pass-access-token` 전제 |
|
||||
|
||||
**전제**: oauth2-proxy 가 `--set-xauthrequest` 로 실행되지 않으면 `X-Auth-Request-*` 응답 헤더 자체가 나오지 않아 위 2-step 이 **조용히 빈 값** 이 된다(`O2PN-C3`). 그 flag 의 owner 는 형제 [[raw/branch-notes/feature-keycloak-oauth2-proxy-oidc-flow]].
|
||||
|
||||
#### `@oauth2_signin` 목적지 — `/oauth2/start` vs `/oauth2/sign_in` (`UNSUPPORTED_IMPL_DECISION` (b))
|
||||
|
||||
본 노트의 기존 TODO 는 `return 302 .../oauth2/start?rd=...` 로 적혀 있으나, **공식 예제(`O2PN-C5`)는 `/oauth2/sign_in?rd=...`** 를 쓴다. 둘 다 실재하는 endpoint 이며 동작이 다르다:
|
||||
|
||||
| 목적지 | 동작 | 언제 |
|
||||
|---|---|---|
|
||||
| `/oauth2/sign_in` | oauth2-proxy 자체 **로그인 페이지** 를 보여줌 (`O2EP-C3`) — 공식 예제 값 | IdP 가 여럿이거나 중간 확인 화면을 원할 때 |
|
||||
| `/oauth2/start` | OAuth cycle 을 **즉시 시작** 하는 redirect (`O2EP-C1`) — 중간 페이지 생략 | IdP 가 Keycloak 하나뿐이라 "Sign in with…" 화면이 군더더기일 때 |
|
||||
|
||||
trade-off: 본 프로젝트는 IdP 가 Keycloak 단일이므로 `/oauth2/start` 가 클릭 1회를 줄인다. 다만 **공식 예제 이탈**이므로 P3A 에서 실제 UX 를 확인하고 확정할 것. 공식이 `/oauth2/start` 를 `error_page` 목적지로 권고한 문장은 없다.
|
||||
|
||||
### 4. 4kb cookie 한도 + buffer 튜닝 (D5, D8)
|
||||
|
||||
> **Trace**: D5/`O2PN-C6`("some provider's cookies can exceed the 4kb limit and so the OAuth2 Proxy splits these into multiple parts. Nginx normally only copies the first `Set-Cookie` header from the auth_request to the response") · D8/`OAUTH2PROXY-C2`, `O2PN-C4`(`--pass-access-token` 존재).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: **튜닝 수치 `16k` 는 전부 사용자 임의**. 공식이 말하는 것은 **4KB 한도의 존재와 cookie split 동작뿐** — `proxy_buffer_size`/`proxy_buffers`/`large_client_header_buffers` 를 얼마로 올려야 하는지는 어떤 인용에도 없다. trade-off: 16k 는 "4KB 의 4배" 라는 경험적 여유값이며 메모리를 그만큼 더 쓴다. 실 토큰 크기를 측정해 정하는 것이 옳다(§Claims To Verify).
|
||||
|
||||
| 항목 | 명세 | 근거 |
|
||||
|---|---|---|
|
||||
| 한도 | 일부 provider 의 cookie 가 **4KB 초과** → oauth2-proxy 가 `_oauth2_proxy_0`, `_1`, … 로 split | `O2PN-C6` |
|
||||
| **nginx 의 한계** | nginx 는 auth_request 응답에서 **첫 번째 `Set-Cookie` 만 복사** → split 된 나머지 part 가 유실 | `O2PN-C6` |
|
||||
| 버퍼 튜닝 | `proxy_buffer_size 16k; proxy_buffers 4 16k; large_client_header_buffers 4 16k;` | `UNSUPPORTED_IMPL_DECISION` — 수치 근거 없음 |
|
||||
| 적용 조건 | D8-(a) 선택 시 필수 / D8-(b) 면 여유 | D5 선택 조건 |
|
||||
|
||||
> ⚠️ **미해결**: split cookie 를 nginx 가 모두 복사하게 만드는 정확한 방법(lua 등)은 **인용 범위 밖**(`O2PN-C6` does-not-prove). 즉 D8-(a) 를 택하면 이 branch 의 명세만으로는 첫 로그인이 깨질 수 있으며, 해법은 P3A 에서 별도 조사가 필요하다 — 본 §가 닫지 못한 유일한 in-scope 구멍.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4 캡처용. 정상 경로 외에 *구현 중 부딪힐* 실패/엣지/다른 계약 의존.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **`auth_request` 모듈 미컴파일** (D2 전제): 모듈은 기본 빌드에 없고 `--with-http_auth_request_module` 이 필요(`NGAR-C7`). distribution 패키지가 항상 포함한다는 보장 없음 → 기대 동작: nginx 가 `auth_request` directive 를 unknown 으로 보고 **기동 실패**. 착수 전 `nginx -V 2>&1 | grep auth_request` 로 확인(§Claims To Verify).
|
||||
- **`location = /oauth2/auth` 삭제 시 조용한 회귀** (D6/D7): exact block 을 지우면 `/oauth2/auth` 가 `location /oauth2/` prefix 로 매칭되고(`NGCM-C2`), 그 block 엔 `proxy_pass_request_body off` 가 없으므로 **default `on`(`NGXPM-C1`) 으로 되돌아가 body 가 전달된다**. 에러 없이 동작하므로 탐지가 어렵다.
|
||||
- **`auth_request_set` 누락 시 조용한 인증 우회 착시** (D3): 2-step 중 1단계를 빠뜨리면 nginx 는 여전히 2xx/401 게이팅을 하지만 backend 는 **빈 `X-User`** 를 받는다. backend 가 헤더 유무로 신원을 판단하면 "인증됐는데 익명" 상태가 된다. `--set-xauthrequest` 미설정도 같은 증상(`O2PN-C3`).
|
||||
- **subrequest 5xx / timeout** (D2): `NGAR-C3` 은 "Any other response code returned by the subrequest is considered an error" 만 말하고 **client 가 받는 정확한 코드(500 vs 502)는 미기재**. 즉 oauth2-proxy 가 죽으면 전체 요청이 fail-closed 로 차단되는데, 그 코드가 무엇인지 모른 채 알람을 설계하게 됨(§Claims To Verify).
|
||||
- **split cookie 유실로 첫 로그인 실패** (D5/D8-a): nginx 가 첫 `Set-Cookie` 만 복사(`O2PN-C6`) → 세션이 절반만 심어져 로그인 루프. 해법(lua)이 인용 범위 밖이라 **본 노트만으로는 닫히지 않음**.
|
||||
- **4KB 초과 시 502** (D5): 사용자 추론이며 미검증 — `O2PN-C6` 은 한도와 split 만 말한다(§Claims To Verify).
|
||||
- **API route 에 302 를 반환** (D9): fetch/XHR 이 redirect 를 따라가 JSON 대신 로그인 HTML 을 받거나 CORS 로 실패. `O2PN-C9` 가 이 경로를 "should only be used for browser-facing routes" 로 명시.
|
||||
- **403 은 redirect 로 해소되지 않음** (D2): 401(미인증)과 달리 403(인가 실패)은 재로그인해도 그대로이므로 `@oauth2_signin` 으로 보내면 무한 루프가 된다 → deny 유지.
|
||||
- **다른 계약 의존** (대상 브랜치 + Decision ID 병기):
|
||||
- 부모 [[raw/branch-notes/feature-keycloak-edge-forwardauth-no-google]] `D1`(스택 선택: K8s+ingress-nginx → oauth2-proxy / 단일 VM docker-compose → Traefik) — ⚠️ **긴장 관계**: 본 branch 가 채택한 공식 예제는 **standalone nginx** 기준인데, 부모 D1 은 단일 VM docker-compose 를 Traefik 쪽으로 보낸다. 즉 본 노트의 config 가 실제로 쓰이는 조건은 *K8s + ingress-nginx* 인데, 그 환경에서는 nginx.conf 대신 **annotation 방식**(D7 선택 조건, §범위 Out of scope)이 된다. **부모 D1 의 분기가 유지되는 한 본 §구현 가이드의 nginx.conf 는 "학습용 참조 구현"이지 배포 대상이 아니다** — 이 모순은 본 branch 가 단독으로 풀 수 없고 부모 D1 의 재검토 또는 배포 형태 확정이 선행돼야 한다.
|
||||
- 부모 [[raw/branch-notes/feature-keycloak-edge-forwardauth-no-google]] `D2`(헤더 명명 = `X-Auth-Request-*` 우선) — 본 branch D4 가 전달할 헤더 **이름의 owner**. 부모가 규약을 바꾸면 §3 의 `proxy_set_header` 이름이 따라 바뀐다.
|
||||
- 부모 [[raw/branch-notes/feature-keycloak-edge-forwardauth-no-google]] `D3`(backend 인증 코드 제거 + ForwardAuth 위임) — 본 branch 전체의 **존재 전제**. 이 결정이 뒤집히면 본 노트 전부가 무효.
|
||||
- 형제 [[raw/branch-notes/feature-keycloak-oauth2-proxy-oidc-flow]] — **oauth2-proxy flag 의 owner**. `--set-xauthrequest`(D3/D4 의 전제), `--pass-access-token`(D8-a 의 전제)이 그쪽에서 꺼지면 본 branch 의 헤더 명세가 조용히 빈다.
|
||||
- 형제 [[raw/branch-notes/feature-keycloak-oauth2-proxy-oidc-flow]] — **`--proxy-prefix` (D7 의 전제, blast radius 최대)**: 모든 endpoint 가 `/oauth2` prefix 를 공유하는 것은 **기본값일 뿐 변경 가능**(`O2EP-C8`). 이 flag 가 바뀌면 헤더가 비는 정도가 아니라 §구현 가이드 §0 의 **5개 block 경로 전부 + `auth_request /oauth2/auth;` + `@oauth2_signin` 의 `/oauth2/start` 가 모두 조용히 404** 가 된다. 형제가 이 값을 확정하기 전에 본 branch 의 경로 의존을 알려야 한다.
|
||||
- 형제 [[raw/branch-notes/feature-keycloak-header-spoofing-defense]] `D5` — **역방향 의존**: 그 branch 가 도입하려는 `X-Internal-Auth-Token` 은 본 branch 의 현재 전달 헤더 목록(D4: `X-Auth-Request-*` only)에 **없다**. 그쪽 D5 를 실 구현하려면 **본 branch 의 proxy 구성이 그 헤더를 주입하도록 확장되는 것이 선행**돼야 한다(신규 Decision 필요 — 현재 미존재 계약).
|
||||
- 형제 [[raw/branch-notes/feature-keycloak-header-spoofing-defense]] 전체 — 본 branch D3 가 주입하는 `X-User` 를 backend 가 신뢰해도 되는 근거는 **본 branch 가 제공하지 않는다**(`O2PN-C3` does-not-prove). 네트워크 격리가 없으면 D3 는 보안적으로 무의미해진다.
|
||||
- [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] — D8-(a) 채택 시 backend RS 의 audience 검증이 **필수 선행**(`O2PN-C4` does-not-prove: token forwarding ≠ audience validation).
|
||||
- **범위 밖(본 branch 미소유)**: K8s ingress-nginx annotation 구성 / Traefik `forwardAuth` 매핑 / oauth2-proxy 의 provider·cookie secret 구성.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 vendor docs 가 contract 의 존재를 증명해도 내 학습 시연에서 실제 nginx 빌드의 동작은 별개. 다음은 P3A 또는 실 구성 단계에서 실측해야 할 주장.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 사용하는 nginx 패키지에 `--with-http_auth_request_module` 이 컴파일되어 있는지 | `NGAR-C7` 은 모듈이 기본 빌드 아님 + configure 옵션 필요를 명시. distribution 별 패키지가 항상 포함한다는 보장 없음 | `nginx -V 2>&1 \| grep auth_request` 출력 확인 (Alpine / Debian / Amazon Linux 등 환경별 차이) | `needs-confirmation` |
|
||||
| nginx 가 oauth2-proxy 의 multi-part `Set-Cookie` 헤더를 모두 응답에 복사하는지 | `O2PN-C6` 은 nginx 가 기본적으로 첫 번째 Set-Cookie 만 복사한다고 명시. lua 스크립트 또는 별도 처리 필요 | curl 로 첫 로그인 후 응답 `Set-Cookie` 헤더 개수 확인 + `_oauth2_proxy_0`, `_1`, ... 모두 도달했는지 검증 | `planned` |
|
||||
| subrequest 가 5xx 또는 timeout 시 nginx 가 정확히 어떤 응답 코드를 client 에 반환하는지 | `NGAR-C3` 은 "Any other response code is considered an error" 만 명시, 500 vs 502 구분 없음 | oauth2-proxy 를 의도적으로 다운시킨 후 nginx 응답 코드 측정 | `needs-confirmation` |
|
||||
| `auth_request_set` 의 변수가 동일 location 내 여러 `proxy_set_header` 에 안정적으로 사용되는지 (변수 lifetime) | `NGAR-C5` 는 변수가 authorization request 완료 후 set 됨을 명시. 그러나 location 분기 / rewrite 후의 변수 lifetime 은 인용에 없음 | nested location + rewrite 시나리오 작성 후 backend 가 받는 `X-User` 헤더 값 추적 | `planned` |
|
||||
| 4kb cookie 한도 함정이 access token 포함 시 실제로 502 를 유발하는지 | `O2PN-C6` 은 4kb 한도와 cookie split 만 명시. 502 발생 메커니즘은 본 사용자 메모의 추론 | access token 을 cookie 에 담은 상태에서 nginx 기본 buffer 로 시연 후 502 발생 여부 + 튜닝 (`proxy_buffer_size 16k`) 후 정상화 확인 | `needs-confirmation` |
|
||||
| XHR / API client 가 401 → 302 redirect 를 받았을 때의 동작 (브라우저 fetch 의 redirect follow 정책) | **(2026-07-17 부분 해소)** — "API client 를 별도 처리해야 한다"는 *원칙* 자체는 이제 `O2PN-C9` 로 공식 근거 확보(302 는 browser-facing route 전용, API/machine 은 plain 401/403) → **D9 로 승격**. 다만 브라우저 `fetch` 가 302 를 실제로 어떻게 follow 하는지와 CORS preflight 영향은 `O2PN-C9` 인용 범위 밖(does-not-prove) — 여전히 실측 필요 | fetch / axios 로 protected endpoint 호출 후 redirect follow 동작 + CORS preflight 영향 확인. D9 적용 전(302)/후(`error_page 401 =401`) 응답을 비교 | `planned` |
|
||||
| subrequest 에 body 를 전달하면 실제로 POST endpoint 오발동 또는 oauth2-proxy CPU 증가가 발생하는지 | **D6 의 Open Risk 에 있던 이 인과 서술은 2026-07-17 조사로 검증되지 않았다.** `O2PN-C8`/`NGAR-C8`/`NGXPM-C1` 은 (1) 공식 예제가 `off` 를 포함한다는 사실, (2) default 가 `on` 이라는 시맨틱만 증명하고, **body 를 넘겼을 때의 실패 모드는 어느 원문에도 없다**. 순수 사용자 추론이므로 D6 의 *근거* 로 재진술 금지 | `proxy_pass_request_body` 를 의도적으로 `on` 으로 둔 상태에서 대용량 body POST 를 반복 재현 → oauth2-proxy 로그(요청 body 수신 여부)와 CPU/메모리 관찰. 오발동 여부는 `/oauth2/auth` 가 body 를 읽는지로 판정 | `needs-confirmation` |
|
||||
| `location = /oauth2/auth` 에 `internal;` 을 부착해도 `/oauth2/start`·`/oauth2/callback` 등 public endpoint 도달성이 깨지지 않는지 | `NGCM-C1`(auth_request 가 공식 internal-request 트리거 목록에 포함) + `NGCM-C2`(exact match 우선)로 **기술적 가능성** 은 근거 확보. 그러나 "그러므로 안전하다"는 결론은 3개 사실을 **결합한 추론**이며 어떤 공식 문서도 직접 말하지 않는다. 공식 예제 자체는 `internal;` 을 쓰지 않는다(`O2PN-C7` negative finding) | `internal;` 부착 후 (a) 브라우저로 `/oauth2/start` 진입 → 로그인 완결되는지, (b) 외부에서 `curl /oauth2/auth` → 404 반환되는지, (c) 보호 route 의 auth_request 는 정상 동작하는지 3종 확인 | `needs-confirmation` |
|
||||
| nginx buffer 튜닝 수치(`16k`)가 본 프로젝트의 실제 Keycloak 토큰 크기에 적정한지 | 공식은 **4KB 한도의 존재** 만 말하고(`O2PN-C6`) 권장 버퍼 수치를 제시하지 않는다 — `16k` 는 "4KB 의 4배" 라는 사용자 임의값(§구현 가이드 §4 `UNSUPPORTED_IMPL_DECISION`) | 실제 Keycloak realm 의 access/refresh 토큰과 세션 cookie 크기를 측정한 뒤 필요한 버퍼를 역산. 과도한 값은 메모리 낭비이므로 실측 기반으로 확정 | `planned` |
|
||||
| `@oauth2_signin` 의 목적지를 `/oauth2/start` 로 쓰는 것(현 TODO)이 공식 예제의 `/oauth2/sign_in`(`O2PN-C5`) 대비 UX·동작상 문제가 없는지 | 두 endpoint 는 동작이 다르다 — `/oauth2/start` 는 OAuth cycle 즉시 시작(`O2EP-C1`), `/oauth2/sign_in` 은 로그인 페이지 표시(`O2EP-C3`). 공식 예제는 후자를 쓴다. 단일 IdP(Keycloak) 환경에서 전자가 낫다는 것은 **사용자 판단**이며 공식 권고 아님 | 두 목적지로 각각 구성해 브라우저 진입 → Keycloak 로그인 → 원 URL 복귀(`rd` 파라미터)까지의 클릭 수와 중간 화면 유무 비교 | `planned` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 아직 없음(문서 단계).
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/nginx-auth-request-module-official]]
|
||||
- [[raw/official-docs/nginx-core-module-location-internal-official]]
|
||||
- [[raw/official-docs/oauth2-proxy-endpoints-official]]
|
||||
- [[raw/official-docs/oauth2-proxy-nginx-integration-official]]
|
||||
- [[raw/official-docs/oauth2-proxy-overview-config-official]]
|
||||
- [[raw/official-docs/proxy-pass-request-body-nginx-official]]
|
||||
- [[raw/official-docs/security-jwt-rfc-7519-validation]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
> 본 sub-sub-branch 는 leaf — 자식 자료 없음. Phase 3 P3A 실 구현 또는 외부 산출물 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 오류 기록 (이 sub-sub-branch 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 documented-only 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — Phase 3 실 구현 단계에 누적)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 본 sub-sub-branch는 **문서까지만**. wiki 추출은 root branch의 비교 매트릭스 시점에 일괄 처리.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: 해당 없음 (문서 단계)
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목: 없음
|
||||
- `locally-verified` 항목: 없음
|
||||
- `prod-verified` 항목: 없음
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
- 본 sub-sub-branch 전체가 `documented-only` 등급. P1A 부모 sub-branch와 함께 추후 비교 매트릭스 / `wiki/concepts/keycloak-deployment-patterns.md`에 인용되는 형태로만 활용. `wiki/projects/` 직접 승급 없음.
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
---
|
||||
title: branch / feature-keycloak-oauth2-proxy-oidc-flow (P1A — oauth2-proxy 구성과 OIDC 흐름)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-PATTERNS-OVERVIEW-012
|
||||
kind: project-work-item
|
||||
project: keycloak-patterns-overview
|
||||
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-012
|
||||
inherits: [DEC-KEYCLOAK-PATTERNS-OVERVIEW-EDGE-FORWARDAUTH-001@1, DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-KEYCLOAK-PATTERNS-OVERVIEW-002]
|
||||
contract_packet: 1
|
||||
branch: feature-keycloak-oauth2-proxy-oidc-flow
|
||||
parent_branch:
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, p1a, oauth2-proxy, oidc, cookie-session]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 844b40d60f42d3186b5952aa27da4febf3da180c1f9fe017400afa625e0d7a36
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-oauth2-proxy-oidc-flow (P1A — oauth2-proxy 구성과 OIDC 흐름)
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/project-notes/keycloak-patterns-overview]] Work Item의 직접 자식 branch.
|
||||
> oauth2-proxy 단독 컴포넌트의 구성과 OIDC 흐름을 **단계별**로 분해. nginx 통합은 별도 sub-sub 에서 다룬다.
|
||||
> 본 sub-sub-branch는 **문서까지만** (`documented-only`). 실 구성/시연 대상 아님.
|
||||
> `status_label`: `in-progress`
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/project-notes/keycloak-patterns-overview]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: unauthenticated redirect와 login 후 X-Forwarded-User backend 200이 재현된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-EDGE-FORWARDAUTH-001@1` | AP4는 oauth2-proxy ForwardAuth를 사용한다 | oauth2-proxy OIDC flow와 forwarded-user 전달 경계에 적용한다 | [[raw/project-notes/keycloak-patterns-overview]] |
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1` | done-bar는 E2E success와 signature security failure 재현·해결 evidence다 | unauthenticated redirect와 login 후 backend 200 재현 증거에 적용한다 | [[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 -->
|
||||
## 목표
|
||||
|
||||
P1A 패턴의 핵심 컴포넌트인 **oauth2-proxy 자체의 설정과 OIDC handshake**를 단계별로 이해. 부모 sub-branch가 다이어그램/sequence 수준을 정리했다면, 본 문서는 **`provider=keycloak-oidc` 설정 + cookie session + OIDC discovery + JWT bearer 검증 경로** 각각이 어디서 동작하고 어떤 함정이 있는지를 분리해서 본다.
|
||||
|
||||
핵심 질문:
|
||||
1. `provider=keycloak-oidc`와 `provider=oidc`(generic)의 실질 차이는? → role/group claim 매핑 + Keycloak userinfo endpoint 처리.
|
||||
2. cookie domain · cookie secret · `--whitelist-domain`은 각각 어떤 공격 surface를 막는가?
|
||||
3. cookie 없이 Bearer JWT를 검증하는 경로는 언제 쓰며, 실제 검증 메커니즘은 무엇인가? (RFC 7662 introspection 호출로 부르지 않음)
|
||||
4. 로그아웃 시 Keycloak 세션까지 끊기 위한 흐름은? (RP-Initiated Logout)
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- `provider=keycloak-oidc` 설정 (`--client-id`, `--client-secret`, `--oidc-issuer-url`, `--redirect-url`)
|
||||
- OIDC discovery / scopes / cookie 설정 (`--cookie-secret`, `--cookie-domain`, `--cookie-secure`, `--cookie-samesite`, `--cookie-expire`)
|
||||
- JWT bearer 검증 경로 vs cookie session 경로 (`--skip-jwt-bearer-tokens`; RFC 7662 introspection과 구분)
|
||||
- 로그아웃 흐름 (RP-Initiated Logout)
|
||||
- role/group claim 매핑
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- nginx 통합 (별도 sub-sub `feature-keycloak-nginx-auth-request-integration`)
|
||||
- 헤더 spoofing 방어 (별도 sub-sub `feature-keycloak-header-spoofing-defense`)
|
||||
- Traefik ForwardAuth 대안 비교 (별도 sub-sub `feature-keycloak-traefik-forwardauth-alternative`)
|
||||
- 실 환경 구성 (P3A 한정, 본 sub-sub는 문서까지만)
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 상단 2개는 부모 sub-branch에서 인용한 외부 자료를 재참조. 나머지 5개는 **2026-07-17 `/branch-spec` 자동조사**로 본 sub-sub-branch 에서 신규 보존 — D5(cookie/session storage) · D6(logout) · D7(whitelist-domain) · D8(JWT bearer 분기) · D9(discovery) 의 UNSUPPORTED 해소용.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/oauth2-proxy-overview-config-official]] | oauth2-proxy 공식 (Reverse proxy + auth provider integration) — `provider=keycloak-oidc` 채택 근거 |
|
||||
| [[raw/official-docs/oauth2-proxy-keycloak-oidc-provider-official]] | oauth2-proxy ↔ Keycloak OIDC 연동 — cookie session / scope mapping 결정 근거 |
|
||||
| [[raw/official-docs/oauth2-proxy-cookie-redirect-flags-official]] | cookie 설정 표준화(D5: `--cookie-secret`/`--cookie-domain`/`--cookie-secure`/`--cookie-samesite`/`--cookie-expire`/`--cookie-refresh`) + open redirect 방어(`--whitelist-domain`) + OIDC discovery 우회(`--skip-oidc-discovery`) 근거 |
|
||||
| [[raw/official-docs/oauth2-proxy-session-storage-official]] | D5 — session storage 백엔드(cookie vs redis) 선택의 공식 메커니즘 근거 (stateless cookie 저장, 세션 lock 부재, Redis ticket/SETEX 메커니즘, `--session-store-type`/`--redis-connection-url`/Sentinel·Cluster 플래그) |
|
||||
| [[raw/official-docs/oauth2-proxy-endpoints-signout-official]] | `/oauth2/sign_out` 기본 동작(로컬 cookie만 삭제) + `rd` query parameter/`{id_token}` placeholder 로 Keycloak `end_session_endpoint` 트리거하는 메커니즘 — D6 (RP-Initiated Logout) 근거 |
|
||||
| [[raw/official-docs/oauth2-proxy-behaviour-cookie-vs-bearer-official]] | oauth2-proxy 요청 검증 분기(opportunistic cookie/JWT 검증, invalid JWT fallback, 401/403/redirect 조건) 근거 — Bearer 경로를 RFC 7662 introspection으로 부를 근거는 없으며, 로컬 JWKS 검증 여부는 별도 확인 필요 |
|
||||
| [[raw/official-docs/keycloak-oidc-logout-endpoint-official]] | Keycloak 측 RP-Initiated Logout 요구사항 — `end_session_endpoint`(`/realms/{realm}/protocol/openid-connect/logout`) + `id_token_hint` + `post_logout_redirect_uri` 파라미터 명세 + Backchannel Logout URL client 설정 — D6 (RP-Initiated Logout) 의 Keycloak 측 근거 (oauth2-proxy 측은 위 `oauth2-proxy-endpoints-signout-official` 가 커버) |
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기.
|
||||
|
||||
- [ ] `provider=keycloak-oidc` 설정 정리 (`--client-id`, `--client-secret`, `--oidc-issuer-url`, `--redirect-url`) — 등급: `planned`
|
||||
- [ ] OIDC discovery (`.well-known/openid-configuration`) 호출 시점과 cache 정책 정리 — 등급: `planned`
|
||||
- [ ] OIDC scopes 정리 (`openid profile email`, `groups`, `offline_access`) + Keycloak client scope 매핑 — 등급: `planned`
|
||||
- [ ] cookie 설정 정리: `--cookie-secret` 생성(32byte), `--cookie-domain`, `--cookie-secure`, `--cookie-samesite=lax|strict`, `--cookie-expire` — 등급: `planned`
|
||||
- [ ] cookie session 모드 vs Redis session store 모드 비교 — 등급: `planned`
|
||||
- [ ] `--whitelist-domain` 옵션의 역할 (open redirect 방지) 정리 — 등급: `planned`
|
||||
- [ ] JWT bearer 검증 경로 (`--skip-jwt-bearer-tokens`, `--extra-jwt-issuers`)의 의미와 실제 검증 메커니즘 정리 — RFC 7662 introspection으로 단정하지 않음 — 등급: `planned`
|
||||
- [ ] 로그아웃 흐름 정리: `/oauth2/sign_out` + Keycloak RP-Initiated Logout (`end_session_endpoint`) 연계 — 등급: `planned`
|
||||
- [ ] role/group claim 매핑: oauth2-proxy `--allowed-group` + Keycloak `groups` client scope mapper — 등급: `planned`
|
||||
- [ ] 학습 시연 단계 정리 (실행 안 함, 문서상의 가상 단계만) — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
> 작업하며 떠오른 메모.
|
||||
|
||||
- `provider=oidc` (generic)도 Keycloak에 동작하지만, `keycloak-oidc`는 group/role 매핑이 native라 `--allowed-group` 같은 옵션이 자연스럽게 동작.
|
||||
- cookie session 모드는 access_token 자체를 cookie에 넣을 수 있어 nginx 헤더 4kb 한도 함정과 직결 (sub-sub `feature-keycloak-nginx-auth-request-integration`에서 다룸).
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- **2026-05-25**: 본 sub-sub는 oauth2-proxy 단독 컴포넌트 학습으로 한정. nginx 통합·헤더 spoofing 방어·Traefik 대안은 형제 sub-sub 에서. 분리 이유 = 각 토픽의 함정이 서로 독립적이라 한 문서에 합치면 비교가 흐려짐.
|
||||
- **2026-07-17** (`/branch-spec` 자동조사): D5(cookie 속성/session storage) · D6(RP-Initiated Logout) 의 `UNSUPPORTED_DECISION` 해소. 공식 문서 5건을 신규 보존해 D5a/D5b/D5c 로 분해하고, D6 을 oauth2-proxy 측(`O2PE-C1`~`C4`) + Keycloak 측(`KC-LOGOUT-C1`~`C6`) 양측 근거로 승격. 추가로 D7(`--whitelist-domain`) · D8(JWT bearer 분기) · D9(OIDC discovery) 를 신규 결정으로 분리. / 검토한 대안: logout 은 back-channel logout·로컬 cookie 삭제만 두 대안을 비교했고 → RP-Initiated 채택(back-channel 은 oauth2-proxy 수신 지원 미확인, §Audit `BACKCHANNEL_UNVERIFIED`). session storage 는 cookie·Redis 비교 → **인스턴스 수가 아니라 세션 payload 크기가 실제 결정 변수**임을 확인하고 조건부로 남김. / 근거: [[raw/official-docs/oauth2-proxy-endpoints-signout-official]], [[raw/official-docs/keycloak-oidc-logout-endpoint-official]], [[raw/official-docs/oauth2-proxy-cookie-redirect-flags-official]], [[raw/official-docs/oauth2-proxy-session-storage-official]], [[raw/official-docs/oauth2-proxy-behaviour-cookie-vs-bearer-official]]
|
||||
- **2026-07-18** (drift 해소): 기존 **"token introspection 모드"** 명칭을 **"JWT bearer 검증 경로"**로 교정. 공식 문서가 RFC 7662 introspection endpoint 호출을 서술하지 않으므로 두 메커니즘을 동일시하지 않는다. 로컬 JWKS 검증 여부는 실측 전까지 `needs-confirmation`으로 유지한다.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. 본 sub-sub-branch 의 결정 (D1) 은 학습 범위 분할 (scoping) 결정으로 외부 vendor doc 인용 없음 — UNSUPPORTED_DECISION 으로 표기.
|
||||
> TODO 항목 중 외부 vendor doc 으로 뒷받침되는 것은 D2~ 로 분리해 명시.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | 본 sub-sub 는 oauth2-proxy 단독 컴포넌트 학습으로 한정 (nginx 통합 / spoofing 방어 / Traefik 대안 분리) | N/A — 학습 범위 분할(organizational). 분기 없음 | UNSUPPORTED_DECISION (학습 분할 결정은 내부 scoping — 외부 vendor doc 인용 불요) | N/A (organizational decision) | 분할이 너무 잘게 쪼개져 다 모았을 때 비교 매트릭스를 다시 합성해야 하는 비용 |
|
||||
| D2 | `provider=keycloak-oidc` + `--client-id` + `--client-secret` + `--oidc-issuer-url` 4종 파라미터를 oauth2-proxy ↔ Keycloak 연결의 필수 입력으로 채택 | Keycloak **17+** → `--oidc-issuer-url=https://<host>/realms/<realm>`. **17 미만** → `/auth/realms/<realm>` (legacy context path). group/role 을 oauth2-proxy 레벨에서 안 쓸 거면 generic `provider=oidc` 도 가능하나 D4 의 native 매핑을 잃음 | `raw/official-docs/oauth2-proxy-keycloak-oidc-provider-official.md#O2PK-C1`, `raw/official-docs/oauth2-proxy-keycloak-oidc-provider-official.md#O2PK-C2`, `raw/official-docs/oauth2-proxy-overview-config-official.md#OAUTH2PROXY-C5` | `official-vendor-doc` (oauth2-proxy 공식 + Keycloak 17+ issuer URL 패턴 명시) | Keycloak 17+ context-path (`/realms/` vs `/auth/realms/`) 가 reverse-proxy 가 `/auth` prefix 를 재추가한 환경에서 어떻게 동작하는지 미검증 |
|
||||
| D3 | OIDC scopes 정리: `openid profile email` + `groups` (group authorization 필요 시) + `offline_access` (refresh token 필요 시) — Keycloak client scope 매핑 필요 | `--allowed-group` 을 쓸 때만 `groups` scope + Group Membership mapper 추가(O2PK-C5). refresh token 이 필요할 때만 `offline_access` — 불필요하면 빼서 세션 payload 를 줄임(D5a 의 4kb 압력과 직결) | `raw/official-docs/oauth2-proxy-keycloak-oidc-provider-official.md#O2PK-C5` (groups client scope 필요), `raw/official-docs/oauth2-proxy-keycloak-oidc-provider-official.md#O2PK-C3` | `official-vendor-doc` (**`groups` 부분만**) + `UNSUPPORTED_DECISION` (**base scope · `offline_access` 부분**) | ① client scope 이름이 정확히 `groups` 가 아닐 때 동작 보장 안 됨 — default vs optional scope 구분 별도. ② **base scope 문자열(`openid profile email`)과 `offline_access`↔refresh token 관계는 본 branch 근거 raw 에 문자열이 0건** — `O2PK-C3`/`C5` 는 `groups` client scope + Group Membership mapper 만 증명한다. 이 부분은 OIDC 일반 배경지식에서 온 사용자 임의 결정이며 벤더 권고가 아님(§구현 가이드 1 의 `--scope` `UNSUPPORTED_IMPL_DECISION` 참조) |
|
||||
| D4 | role/group claim 매핑: `--allowed-role=<realm role>` 또는 `--allowed-role=<client>:<client role>` + `--allowed-group=</group>` | realm 전역 권한 → `--allowed-role=<realm role>`. 특정 client 한정 권한 → `--allowed-role=<client id>:<client role>`. 조직 트리 기반 → `--allowed-group=</group>` (+ D3 의 `groups` scope 필수). 인가를 edge 에서 안 하고 backend 로 미룰 거면 셋 다 미설정("valid user" 만 요구, O2PK-C3) | `raw/official-docs/oauth2-proxy-keycloak-oidc-provider-official.md#O2PK-C3`, `raw/official-docs/oauth2-proxy-keycloak-oidc-provider-official.md#O2PK-C4` | `official-vendor-doc` | **인가(authorization) 실패 시 응답 코드는 여전히 미확인.** D8 의 `O2PBEH-C2~C4` 는 *인증(authentication)* 단계의 401/403/redirect 만 증명 — role/group 불일치 시의 코드는 별도. nginx `error_page` 처리에 영향 |
|
||||
| D5a | session storage 백엔드 기본값 = cookie (`--session-store-type=cookie`, stateless, 클라이언트 저장 + 매 요청 전송, 세션 lock 부재로 동시 refresh 시 재인증 강제 가능) | 세션 payload 가 4kb 미만으로 유지되고(= D3 에서 `offline_access`/과다 role claim 회피) 컴포넌트 최소화가 우선이면 cookie. payload 가 4kb 를 넘길 여지가 있거나 access_token 을 backend 로 전달하면 → **D5b(Redis)**. 인스턴스 개수는 이 선택의 기준이 **아님**(단일 EC2 여도 4kb 압력은 동일) | `raw/official-docs/oauth2-proxy-session-storage-official.md#O2PSESS-C1`, `raw/official-docs/oauth2-proxy-session-storage-official.md#O2PSESS-C2`, `raw/official-docs/oauth2-proxy-nginx-integration-official.md#O2PN-C6` (**4kb 임계값의 실제 owner claim** — session-storage 문서가 아니라 nginx 통합 문서에 있음) | `official-vendor-doc` | 쿠키 실제 바이트 한도·4kb 초과 시 분할(split) 동작은 이 자료에 없음 — 별도 raw source 필요 (`OAUTH2PROXY-SESSION-STORAGE` 문서 §Usage Boundaries 참고). Azure/Google federation 사례의 큰 토큰 크기를 Keycloak native 환경에 일반화 금지 |
|
||||
| D5b | Redis session store 채택 시 `--session-store-type=redis` + `--redis-connection-url=redis://host[:port][/db-number]` 로 연결하며, 클라이언트에는 ticket(`{CookieName}-{ticketID}.{secret}`)만 전달 (세션 본문은 서버측 Redis 에 `SETEX` 로 암호화 저장). Sentinel/Cluster 는 `--redis-use-sentinel=true`/`--redis-use-cluster=true` (상호 배타)로 구성 | 4kb 초과 위험 **또는** access_token 헤더 전달 중 하나라도 해당하면 Redis. 둘 다 아니면 D5a 로 남김(컴포넌트 1개 추가는 P1A 의 "단일 EC2 최소 구성" 과 상충). standalone 이 기본이고 Sentinel↔Cluster 는 상호 배타이므로 동시 지정 금지. **"다중 replica" 는 트리거가 아님** — cookie store 는 "completely stateless"(`O2PSESS-C1`) 라 `--cookie-secret` 만 공유하면 replica 간 세션이 성립한다. D5a 의 "인스턴스 수는 기준이 아님" 과 정합 | `raw/official-docs/oauth2-proxy-session-storage-official.md#O2PSESS-C3`, `raw/official-docs/oauth2-proxy-session-storage-official.md#O2PSESS-C4`, `raw/official-docs/oauth2-proxy-session-storage-official.md#O2PSESS-C5`, `raw/official-docs/oauth2-proxy-session-storage-official.md#O2PSESS-C6` | `official-vendor-doc` | P1A(단일 EC2, documented-only) 규모에서 Redis 도입이 실제로 "필요"한지는 이 자료가 증명하지 않음 — 메커니즘 존재만 확인. Redis 도입 시 `--cookie-secret` 관리 부담은 사라지지 않음(ticket 암호화에 계속 사용) |
|
||||
| D5c | cookie 속성값 표준화 채택: `--cookie-secret`(seed string, `-file` 변형은 raw binary 16/24/32byte) / `--cookie-domain` / `--cookie-secure=true`(기본값) / `--cookie-samesite=""`(기본값 — 이때 브라우저가 실제로 어떤 SameSite 로 해석하는지는 `O2PCOOKIE-C1` 범위 밖) / `--cookie-expire=168h0m0s`(기본값) / `--cookie-refresh`(기본 비활성, Keycloak 은 지원 provider 목록에 포함) | HTTPS 종단이 있으면 `--cookie-secure=true`(기본값 유지). 순수 로컬 `http://` 시연에 한해서만 `false` — 이 경우 "로컬 한정 예외" 라벨 필수. `--cookie-csrf-samesite` 를 따로 안 주면 CSRF 쿠키가 세션 쿠키의 samesite 를 **상속**(O2PCOOKIE-C6)하므로, samesite 를 조일 때 두 값을 함께 판단 | `raw/official-docs/oauth2-proxy-cookie-redirect-flags-official.md#O2PCOOKIE-C1`, `#O2PCOOKIE-C2`, `#O2PCOOKIE-C3`, `#O2PCOOKIE-C4`, `#O2PCOOKIE-C5`, `#O2PCOOKIE-C6` | `official-vendor-doc` | 공식 문서는 각 플래그의 존재·기본값만 증명 — P1A 배포에서 `--cookie-samesite` 를 `lax`/`strict`/`none` 중 무엇으로 명시할지는 별도 아키텍처 결정(교차 사이트 redirect 여부에 따름), byte 길이 제약이 `--cookie-secret-file` 행에만 명시돼 `--cookie-secret` 자체에도 적용되는지는 미확정 |
|
||||
| D6 | RP-Initiated Logout 채택 — `/oauth2/sign_out?rd=<Keycloak end_session_endpoint>` 형태로 **`rd` query parameter**(또는 `X-Auth-Request-Redirect` 헤더)에 Keycloak `end_session_endpoint`(`/realms/{realm}/protocol/openid-connect/logout`)를 지정하고, `{id_token}` placeholder 로 `id_token_hint` 를 주입. **전제: 그 도메인이 `--whitelist-domain` 에 등록돼야 함(D7)** | Keycloak 세션까지 끊어야 하면 이 결정. oauth2-proxy 로컬 cookie 만 지우면 충분하면 기본 `/oauth2/sign_out`(rd 없이) — 단 이 경우 **IdP 세션이 남아 재접근 시 자동 재로그인**(O2PE-C1)되므로 "로그아웃이 안 된 것처럼" 보임. `post_logout_redirect_uri` 를 쓰려면 `client_id` 또는 `id_token_hint` 중 하나를 반드시 동반(KC-LOGOUT-C5) | `raw/official-docs/oauth2-proxy-endpoints-signout-official.md#O2PE-C1`, `#O2PE-C2`, `#O2PE-C3`, `#O2PE-C4`, `raw/official-docs/keycloak-oidc-logout-endpoint-official.md#KC-LOGOUT-C1`, `#KC-LOGOUT-C2`, `#KC-LOGOUT-C3`, `#KC-LOGOUT-C4`, `#KC-LOGOUT-C5`, `#KC-LOGOUT-C6` | `official-vendor-doc` (**파라미터 계약** — `rd`/`{id_token}`/`id_token_hint`/`post_logout_redirect_uri`: oauth2-proxy 측 + Keycloak 측 **양측** 교차 확보, 2026-07-17 UNSUPPORTED_DECISION 해소) + `needs-confirmation` (**경로 문자열**) | ① oauth2-proxy 가 back-channel logout **수신자**로 동작하는지는 공식 문서에서 확인 안 됨(§Audit & Findings `BACKCHANNEL_UNVERIFIED`). ② logout 후 Keycloak 세션이 실제로 종료되는지는 여전히 실측 대상(§Claims To Verify). ③ `rd` 대상이 `end_session_endpoint` 여야 한다는 것은 문서의 **권고(convention)** 이지 oauth2-proxy 가 강제 검증하지 않음. ④ **경로 `/realms/{realm}/protocol/openid-connect/logout` 은 `KC-LOGOUT-C1` 의 quote 에 없고 claim 서술문에만 존재** → 하드코딩 금지, discovery 응답의 `end_session_endpoint` 를 읽을 것(§구현 가이드 2 의 3단계 · §Claims To Verify) |
|
||||
| D7 | `--whitelist-domain` 에 redirect 허용 도메인을 **명시 등록**. 서브도메인 전체 허용은 `.example.com` 또는 `*.example.com` prefix 사용 | Keycloak 이 oauth2-proxy 와 **다른 도메인**이면 필수 — 미등록 시 D6 의 logout redirect 가 **조용히 무시**됨(O2PE-C4). 같은 도메인 안에서 상대경로 redirect 만 쓰면 **불필요할 가능성** (단정 불가 — 미설정 시 기본 동작이 공식 문서에 없어 Open Risk 참조. 확정 전까지는 안전측으로 항상 명시 등록 권장). 기본 동작은 URL 프로토콜의 default port(80/443)만 허용하므로, 비표준 포트를 쓰면 포트까지 명시 필요(O2PCOOKIE-C7) | `raw/official-docs/oauth2-proxy-cookie-redirect-flags-official.md#O2PCOOKIE-C7`, `raw/official-docs/oauth2-proxy-endpoints-signout-official.md#O2PE-C4` | `official-vendor-doc` | **미설정 시 기본 동작(외부 도메인 전부 차단인지)이 공식 문서에 명시되지 않음** — `needs-confirmation`. 또한 이 옵션의 suffix 매칭에 과거 우회 취약점 이력이 있다고 **전해지나 본 라운드에서 검증하지 않았다** (미검증 — §Audit & Findings `WHITELIST_CVE_HISTORY`, raw 미보존) → 옵션 설정만으로 open redirect 가 닫힌다고 단정 금지 |
|
||||
| D8 | 요청 검증 분기: 브라우저 요청은 session cookie 경로, `Authorization: Bearer <JWT>` 요청은 `--skip-jwt-bearer-tokens` 경로로 **자동 분기**(택1 아님 — 한 배포에서 공존). invalid JWT 는 기본 로그인 redirect, `--bearer-token-login-fallback=false` 면 403 | API/M2M 클라이언트가 있으면 `--skip-jwt-bearer-tokens` 설정 + `--bearer-token-login-fallback=false`(JSON 클라이언트에 HTML 로그인 페이지 대신 403 반환). 브라우저 전용이면 기본값 유지. 다른 issuer 의 JWT 도 받으려면 `--extra-jwt-issuers=<issuer>=<audience>` | `raw/official-docs/oauth2-proxy-behaviour-cookie-vs-bearer-official.md#O2PBEH-C1`, `#O2PBEH-C2`, `#O2PBEH-C3`, `#O2PBEH-C4` | `official-vendor-doc` (**실패 3경로** — `O2PBEH-C2`/`C3`/`C4`) + `UNSUPPORTED_DECISION` (**인증 강제 라우트에서의 cookie/JWT 공존·우선순위**) | ① **명칭 경계** — 이 경로는 **JWT bearer 검증 경로**이며 RFC 7662 introspection 호출로 부르지 않는다. 로컬 JWKS 서명 검증 여부도 behaviour 페이지가 직접 명시하지 않아 **미확정**이다. ② **헤드라인의 "자동 분기·공존" 은 공식 보장이 아님** — `O2PBEH-C1` 은 `--skip-auth-route` **전용 인용**이고 그 does-not-prove 가 강제 라우트에서의 cookie/JWT 순서·우선순위를 범위 밖으로 못박는다. 통과 경로는 실패 경로(`C3`/`C4`)의 대우에서 도출한 추론(§구현 가이드 3 · §Claims To Verify) |
|
||||
| D9 | OIDC discovery 활성(기본) — `--oidc-issuer-url` 로부터 `.well-known/openid-configuration` 자동 조회. 우회하려면 `--skip-oidc-discovery` + `--login-url`(Authentication endpoint) / `--redeem-url`(Token redemption endpoint) / `--oidc-jwks-url` **3종 전부** 수동 지정 | 네트워크로 issuer 에 도달 가능하면 기본값(discovery 활성). **폐쇄망 등으로 issuer 도달이 불가능**하면 `--skip-oidc-discovery` + 3종 수동(이게 `O2PCOOKIE-C8` 이 실제로 닫는 축). 서명 키를 정적으로 고정하려면 `--oidc-public-key-file`(PEM) — 단 키 rotation 시 수동 재배포 필요. **기동 순서(Keycloak 이 늦게 뜨는 문제)는 이 결정의 축이 아니다** — 그건 [[raw/branch-notes/feature-keycloak-docker-compose-stack]] `D3`(healthcheck 게이팅)가 owner 이며, discovery 를 끄는 것은 그 문제의 해법이 아님 | `raw/official-docs/oauth2-proxy-cookie-redirect-flags-official.md#O2PCOOKIE-C8`, `#O2PCOOKIE-C9` | `official-vendor-doc` | **discovery 호출 시점(기동 1회 vs 주기적)과 JWKS cache TTL 이 공식 prose 문서에 없음** — `needs-confirmation`(§Claims To Verify). 기동 시 discovery 실패가 실제 실패 모드인지 확인되면 대응은 compose `D3` 로 위임(본 branch 재진술 금지) |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 본 sub-sub-branch 는 `documented-only` — 실 구성/시연 대상이 아니다. 따라서 본 §는 "코드를 어디에 쓸 것인가" 가 아니라 **학습 시연 문서상의 가상 구성 명세**(TODO 마지막 항목)로 읽는다. P3A 실 구현 단계에서 이 명세가 실제 config 의 출발점이 된다.
|
||||
> 3-rule (CLAUDE.md §15.5) 적용: 각 sub-section 은 Decision ID + Supporting Claim ID 를 Trace 하고, 근거 없는 detail 은 `UNSUPPORTED_IMPL_DECISION` 라벨 + trade-off 한 줄을 단다.
|
||||
|
||||
### 1. oauth2-proxy 기동 플래그 세트 (문서상 가상 구성)
|
||||
|
||||
> **Trace**: D2(`O2PK-C1`/`O2PK-C2`/`OAUTH2PROXY-C5`) + D3(`O2PK-C3`/`O2PK-C5`) + D4(`O2PK-C3`/`O2PK-C4`) + D5a·D5c(`O2PSESS-C1`/`O2PSESS-C2`, `O2PCOOKIE-C1`~`C6`) + D9(`O2PCOOKIE-C8`/`C9`).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `--cookie-samesite=lax` 로 *명시* 하는 것 — 공식 문서는 기본값이 `""`(빈 문자열)임만 증명하고(`O2PCOOKIE-C1`) 어느 값을 쓰라고 권고하지 않는다. trade-off: OIDC 콜백이 cross-site top-level GET redirect 라 `lax` 가 CSRF 쿠키를 통과시키는 최소값으로 판단 — `strict` 는 콜백 실패 위험, `none` 은 CSRF 표면 확대. **P3A 실측 전까지 확정 아님.**
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `--cookie-secret` 을 32byte 로 생성하는 관행 — 공식 문서는 16/24/32byte 제약을 `--cookie-secret-file`(raw binary) 행에만 명시하고(`O2PCOOKIE-C3`) `--cookie-secret`(seed string) 자체에 같은 제약이 걸리는지는 서술하지 않는다. trade-off: AES-256 을 쓰는 32byte 가 세 허용값 중 최댓값이라 안전측 선택.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `--cookie-domain` 의 값 — D5c 결정문이 이 플래그를 "표준화 대상" 으로 열거하나, **보존된 claim(`O2PCOOKIE-C1`~`C6`) 중 `--cookie-domain` 을 다루는 것은 없다**(C1 samesite / C2 secure / C3 secret / C4 expire / C5 refresh / C6 csrf-samesite). trade-off: oauth2-proxy 와 앱이 같은 host 면 미설정(host-only cookie)이 최소 표면; 서브도메인으로 분리되면 `.example.com` 로 넓혀야 하나 그만큼 쿠키 전송 범위가 커짐. **미설정 시 host-only 가 되어 서브도메인 구성에서 로그인 루프의 원인이 될 수 있음** — P3A 에서 실측 필요.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `--scope` 의 base 값 `openid profile email` 과 "`offline_access` 는 refresh 필요 시만" 조건 — `O2PK-C5`(groups scope + mapper 필요) / `O2PK-C3`(인가 확장) 어느 것도 base scope 문자열이나 `offline_access` ↔ refresh token 관계를 서술하지 않는다(OIDC 일반 배경지식). trade-off: `openid` 는 OIDC 필수, `profile email` 은 `X-Auth-Request-Email` 등 헤더 전달용 관행 — **공식 vendor doc 의 권고 아님**.
|
||||
|
||||
| 플래그 | 값 (학습 시연 가정) | 근거 | 비고 |
|
||||
|---|---|---|---|
|
||||
| `--provider` | `keycloak-oidc` | D2 / `O2PK-C1` | generic `oidc` 대비 role/group native 매핑 확보 |
|
||||
| `--client-id` / `--client-secret` | (realm client 에서 발급) | D2 / `O2PK-C1` | Usage 예시는 confidential client 형식 |
|
||||
| `--oidc-issuer-url` | `https://<keycloak host>/realms/<realm>` | D2 / `O2PK-C2` | Keycloak 26.x = 17+ → `/auth` prefix **없음** |
|
||||
| `--scope` | `openid profile email` (+`groups` 조건부) | D3 / `O2PK-C5` — **base 값 + `offline_access` 조건은 `UNSUPPORTED_IMPL_DECISION`**(위 참조) | `groups` 는 `--allowed-group` 쓸 때만(이건 `O2PK-C5` 근거 있음). `offline_access` 는 refresh 필요 시만 — 넣으면 세션 payload 가 커져 D5a 의 4kb 압력 상승 |
|
||||
| `--cookie-domain` | (미정 — 배포 토폴로지 의존) | **`UNSUPPORTED_IMPL_DECISION`**(위 참조 — 보존 claim 없음) | 같은 host 면 미설정(host-only), 서브도메인 분리 시 `.example.com`. 미설정 + 서브도메인 = 로그인 루프 위험 |
|
||||
| `--allowed-role` / `--allowed-group` | 조건부 (D4 선택 조건 표 참조) | D4 / `O2PK-C4` | 미설정 시 "valid user" 만 요구 |
|
||||
| `--session-store-type` | `cookie` (기본, 단일 EC2 학습 구성) | D5a / `O2PSESS-C1` | 4kb 압력 시 `redis` (D5b) |
|
||||
| `--cookie-secure` | `true` (기본값 유지) | D5c / `O2PCOOKIE-C2` | 로컬 `http://` 시연에 한해 `false` — 예외 라벨 필수 |
|
||||
| `--cookie-expire` | `168h0m0s` (기본값 유지) | D5c / `O2PCOOKIE-C4` | `0` 이면 브라우저 종료 시 만료 |
|
||||
| `--whitelist-domain` | Keycloak 도메인 (D6 전제) | D7 / `O2PCOOKIE-C7`·`O2PE-C4` | **미등록 시 logout redirect 무시** |
|
||||
| `--code-challenge-method` | `S256` | D2 / `O2PK-C6` | PKCE — 형제 branch `feature-keycloak-pkce-flow-stages` 가 owner |
|
||||
|
||||
### 2. 로그아웃 URL 조립 (D6 의 실제 형태)
|
||||
|
||||
> **Trace**: D6(`O2PE-C1`~`C4`, `KC-LOGOUT-C1`~`C6`) + D7(`O2PCOOKIE-C7`).
|
||||
>
|
||||
> - **근거 있는 결정**: `rd` 파라미터 · `{id_token}` placeholder · `id_token_hint`/`post_logout_redirect_uri` 요구사항 (`O2PE-C2`, `O2PE-C3`, `O2PE-C4`, `KC-LOGOUT-C3`, `KC-LOGOUT-C5`, `KC-LOGOUT-C6`) — 양측 공식 문서 verbatim 으로 뒷받침됨.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 3단계의 **경로 문자열** `/realms/<realm>/protocol/openid-connect/logout` — `KC-LOGOUT-C1` 의 evidence quote 전문은 "The logout endpoint logs out the authenticated user." 뿐이고 경로는 quote 에 **없다**(claim 서술문에만 존재). 같은 claim 이 "이 경로가 discovery 문서의 `end_session_endpoint` 필드 값과 동일하게 노출된다는 명시적 문장은 이 인용에 없음" 을 자인. trade-off: 경로를 하드코딩하지 말고 **discovery 응답의 `end_session_endpoint` 를 읽는 것이 안전** — 하드코딩은 Keycloak context-path 변경(D2 의 17+ 이슈)에 취약. `needs-confirmation` (§Claims To Verify).
|
||||
|
||||
**중요**: `--backend-logout-url` 이라는 플래그는 **oauth2-proxy 공식 endpoints 문서에 존재하지 않는다**(§Audit & Findings `MECHANISM_DRIFT`). 실제 메커니즘은 `rd` query parameter + placeholder 치환이다.
|
||||
|
||||
| 단계 | 조립 | 근거 |
|
||||
|---|---|---|
|
||||
| 1. 로그아웃 진입 | 사용자를 `/oauth2/sign_out?rd=<urlencoded end_session URL>` 로 redirect | `O2PE-C1`, `O2PE-C2` |
|
||||
| 2. oauth2-proxy 동작 | 자신의 세션 cookie 만 삭제 → `rd` 대상으로 redirect. **`rd` 도메인이 `--whitelist-domain` 미등록이면 redirect 무시** | `O2PE-C1`, `O2PE-C4` |
|
||||
| 3. `end_session_endpoint` | `https://<keycloak host>/realms/<realm>/protocol/openid-connect/logout` — **discovery 응답에서 읽을 것(하드코딩 금지)** | **`UNSUPPORTED_IMPL_DECISION`** (경로가 `KC-LOGOUT-C1` quote 에 없음 — claim 서술문만) |
|
||||
| 4. `id_token_hint` 주입 | `rd` URL 안에 `{id_token}` placeholder 를 넣으면 oauth2-proxy 가 실제 ID Token 으로 치환 | `O2PE-C3` |
|
||||
| 5. `post_logout_redirect_uri` | 쓰려면 `client_id` **또는** `id_token_hint` 중 하나 필수 + client 의 `Valid Post Logout Redirect URIs` 에 등록돼 있어야 함 | `KC-LOGOUT-C5`, `KC-LOGOUT-C6` |
|
||||
|
||||
### 3. 요청 검증 분기 (D8)
|
||||
|
||||
> **Trace**: D8(`O2PBEH-C1`~`C4`).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: **아래 통과 2행**(`cookie 검증 후 통과` / `valid JWT → 세션 없이 통과`) — 근거인 `O2PBEH-C1` 은 **`--skip-auth-route` 로 인증이 스킵된 라우트** 전용 인용이며("Authentication is not enforced, but the proxy will opportunistically attempt to validate…"), 같은 claim 의 Does-not-prove 가 "스킵되지 않은(=인증 강제) 라우트에서 cookie/JWT 를 각각 어떤 순서·우선순위로 시도하는지는 본 인용 범위 밖" 이라 못박는다. 즉 **인증 강제 라우트의 통과 동작은 공식 미서술** — 아래 2행은 실패 경로(`C3`/`C4`)의 대우(對偶)에서 도출한 추론이다. trade-off: 실패 경로가 명시적으로 정의된 이상 통과 경로가 그 여집합이라고 보는 것이 합리적이나, 공식 보장은 아님. **실패 3행(`C2`/`C3`/`C4`)은 근거 있는 결정.**
|
||||
> - 이 경로의 *명칭*은 §Audit & Findings `NAMING_DRIFT` 참조(구현 detail 이 아니라 용어 문제).
|
||||
|
||||
| 요청 형태 | oauth2-proxy 동작 | 근거 |
|
||||
|---|---|---|
|
||||
| session cookie 보유 브라우저 요청 | cookie 검증 후 통과 | **`UNSUPPORTED_IMPL_DECISION`** (추론 — `C3`/`C4` 실패 경로의 대우. `O2PBEH-C1` 은 skip-auth-route 전용) |
|
||||
| `Authorization: Bearer <valid JWT>` (`--skip-jwt-bearer-tokens` 설정 시) | JWT 검증 후 세션 없이 통과 | **`UNSUPPORTED_IMPL_DECISION`** (추론 — 상동. §Claims To Verify 실측 대상) |
|
||||
| `Authorization: Bearer <invalid JWT>` | **기본: 로그인 페이지 redirect** | `O2PBEH-C3` |
|
||||
| 위 + `--bearer-token-login-fallback=false` | `403 Forbidden` | `O2PBEH-C4` |
|
||||
| 미인증 + `Accept: application/json` | `401 Unauthorized` (redirect 아님) | `O2PBEH-C2` |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4(깊이 게이트) 캡처용. 정상 경로 외에 *학습 시연/후속 구현에서 부딪힐* 실패·엣지와 다른 계약 의존.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **로그아웃이 조용히 실패**: `rd` 대상(Keycloak) 도메인이 `--whitelist-domain` 에 없으면 oauth2-proxy 가 **에러 없이 redirect 를 무시**한다(`O2PE-C4`). 결과적으로 oauth2-proxy cookie 만 지워지고 Keycloak 세션은 살아남아, 재접근 시 자동 재로그인(`O2PE-C1`)되어 **"로그아웃이 안 된 것처럼" 보인다**. D6 과 D7 이 한 몸인 이유 — D7 없이 D6 만 설정하면 D6 은 무효.
|
||||
- **로그아웃 확인 화면**: `id_token_hint` 없이 `end_session_endpoint` 를 호출하면 Keycloak 이 사용자에게 로그아웃 확인을 요구할 수 있다(`KC-LOGOUT-C3`) → 무인 redirect 흐름이 사용자 클릭에서 멈춤.
|
||||
- **post_logout_redirect_uri 거부**: client 의 `Valid Post Logout Redirect URIs` 에 미등록이면 거부(`KC-LOGOUT-C6`), `client_id`/`id_token_hint` 둘 다 없으면 거부(`KC-LOGOUT-C5`).
|
||||
- **동시 요청 세션 충돌**: cookie store 는 세션 lock 이 없어 동시 refresh 시 충돌 → **강제 재인증** 가능(`O2PSESS-C2`). 단일 EC2 여도 다중 탭/병렬 XHR 이면 발생 — 인스턴스 수와 무관.
|
||||
- **세션 4kb 초과**: access_token 을 cookie 에 담으면 4kb 한도에 걸릴 수 있고, nginx 는 `auth_request` 응답의 **첫 `Set-Cookie` 만 복사**하므로 분할 쿠키가 유실될 수 있다 → 로그인 루프. 대응 owner 는 형제 branch(아래 의존 참조). 단, Keycloak native user store(Google federation 없음)라 Azure/Google federation 사례보다 토큰이 작을 가능성 — **실측 전까지 확정 불가**.
|
||||
- **API 클라이언트에 HTML 로그인 페이지 반환**: invalid JWT 의 기본 동작이 로그인 redirect(`O2PBEH-C3`)라 JSON 클라이언트가 HTML 을 받는다 → `--bearer-token-login-fallback=false` 로 403 전환(`O2PBEH-C4`) 필요.
|
||||
- **인가 거부(authentication 성공 + authorization 실패)**: 로그인은 됐으나 `--allowed-role`/`--allowed-group` 에 안 맞는 사용자의 **응답 코드가 미확정**이다. `O2PK-C3` 이 "인가 실패 시 401 vs 403 의 정확한 의미는 본 인용에 명시 없음" 을 자인하고, `O2PBEH-C2`~`C4` 는 **authentication 단계 전용**이라 이 경로를 덮지 못한다(D4 Open Risk). 코드가 안 정해지면 [[raw/branch-notes/feature-keycloak-nginx-auth-request-integration]] `D2`(subrequest 2xx/401/403 contract)의 `error_page` 분기도 못 닫는다 — 그쪽 계약과 맞물린 미결.
|
||||
- **discovery 기동 순서**: Keycloak 이 아직 ready 가 아닌 시점에 oauth2-proxy 가 discovery 를 호출하면 기동에 실패할 수 있음 — **공식 문서로 미확인**(§Claims To Verify). 확인될 경우 대응 owner 는 [[raw/branch-notes/feature-keycloak-docker-compose-stack]] `D3`(healthcheck 게이팅)이며, **`--skip-oidc-discovery`(D9)는 이 문제의 해법이 아니다** — D9 는 issuer *도달 불가*(폐쇄망) 축이지 *기동 순서* 축이 아님.
|
||||
|
||||
- **다른 계약 의존** (대상 브랜치 + 그 Decision ID 병기):
|
||||
- [[raw/branch-notes/feature-keycloak-nginx-auth-request-integration]] `D2`(subrequest 2xx/401/403 contract) + `D3`(`auth_request_set`→`proxy_set_header` 헤더 전파) + `D5`(4kb cookie split 대응) — 본 branch 의 `/oauth2/auth` 엔드포인트(`O2PE-C5`: 202/401 만 반환, nginx `auth_request` 용)와 D5a 의 4kb 압력이 이 계약을 통해 실현된다. 그쪽 계약이 바뀌면 본 branch D5a/D5c 의 cookie 전제가 영향받음.
|
||||
- [[raw/branch-notes/feature-keycloak-header-spoofing-defense]] `D1`(백엔드 ingress-뒤 격리) — 본 branch 는 인증 결과를 헤더로 전달하는 지점까지만 다루고, 그 헤더의 위조 방어는 이 계약이 owner.
|
||||
- [[raw/branch-notes/feature-keycloak-docker-compose-stack]] `D3`(healthcheck 로 의존성 강제 — `depends_on: condition: service_healthy` + Keycloak `/health/ready`) — **기동 순서 게이팅의 owner 는 이 계약이다.** 그쪽 D3 의 선택 조건이 문자 그대로 "app 이 startup 시 keycloak JWKS/issuer discovery 에 의존할 때 이 결정" 이라 본 branch D9(discovery)와 정확히 맞물린다. 본 branch 는 *discovery 측 조건*(끌지 말지)만 소유하고 *게이팅 메커니즘*은 이 계약을 참조만 한다 — 재진술 금지(`rules/consistency-contract` Single-Owner).
|
||||
- [[raw/branch-notes/feature-keycloak-https-termination-caddy-nginx]] `D1`/`D2`(Cloudflare Tunnel / Caddy edge TLS 종단) + `D5`(TLS 1.2+ / HSTS 강제) — D5c 의 `--cookie-secure=true` 전제(HTTPS 종단 존재)가 이 계약에 의존. 종단이 없으면 D5c 의 기본값 유지가 로컬 시연에서 로그인 루프를 만든다.
|
||||
- [[raw/branch-notes/feature-keycloak-pkce-flow-stages]] `D1`(PKCE method = `S256` 만 정리 대상) — `--code-challenge-method=S256`(`O2PK-C6`)의 PKCE 단계 분해는 그쪽이 owner. 본 branch 는 플래그 존재만 인용.
|
||||
- [[raw/branch-notes/feature-keycloak-refresh-rotation-and-logout]] D4(명시적 revoke 와 logout 분리) — 본 branch D6 은 *logout* 만 소유하고 executable revoke/logout 시나리오는 그쪽 경계. rotation 정책은 [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] D1이 소유하며, `--cookie-refresh`(`O2PCOOKIE-C5`)와의 상호작용은 본 branch 에서 재정의하지 않는다.
|
||||
|
||||
## Audit & Findings
|
||||
|
||||
> `/branch-spec` 자동조사(2026-07-17) 중 **기존 노트 본문과 공식 문서가 어긋난 지점**. CLAUDE.md §2 drift-surface 원칙에 따라 사용자 작성 본문을 자동 rewrite 하지 않고 정합 권고만 남긴다.
|
||||
|
||||
| ID | 내용 | 근거 | 권고 |
|
||||
|---|---|---|---|
|
||||
| `NAMING_DRIFT` | **해소(2026-07-18)** — `--skip-jwt-bearer-tokens`/`--extra-jwt-issuers` 경로를 **"JWT bearer 검증 경로"**로 통일했다. 공식 문서가 RFC 7662 introspection endpoint 호출을 서술하지 않으므로 introspection과 동일시하지 않는다. 다만 `.well-known/jwks.json` 참조는 로컬 JWKS 검증을 시사할 뿐 메커니즘을 직접 증명하지 않는다. | `raw/official-docs/oauth2-proxy-behaviour-cookie-vs-bearer-official.md#O2PBEH-C1` + 같은 문서 §Usage Boundaries | 현재 용어를 유지하고, 로컬 JWKS 검증인지 여부만 §Claims To Verify에서 실측한다. |
|
||||
| `MECHANISM_DRIFT` | 자동조사 초기 가설(및 일부 2차 자료)은 로그아웃이 `--backend-logout-url` 플래그로 동작한다고 전제했으나, oauth2-proxy 공식 endpoints 문서에 **`backend.logout` 문자열이 0건**(agent grep 확인). 실제 메커니즘은 `rd` query parameter(또는 `X-Auth-Request-Redirect` 헤더) + `{id_token}` placeholder 치환이며, `rd` 대상이 `end_session_endpoint` 여야 한다는 것은 문서의 **권고(convention)** 이지 강제 검증이 아니다. | `raw/official-docs/oauth2-proxy-endpoints-signout-official.md#O2PE-C2`, `#O2PE-C3` | D6 과 §구현 가이드 2 는 이미 정정된 메커니즘으로 작성됨. 외부 블로그가 `--backend-logout-url` 을 언급하면 버전/오정보 의심. |
|
||||
| `BACKCHANNEL_UNVERIFIED` | oauth2-proxy 가 **back-channel logout 수신자**(Keycloak 이 Logout Token 을 POST 하는 대상)로 동작하는지 공식 endpoints 문서에서 확인 안 됨 — `backchannel`/`logout token` 문자열 0건(agent grep). Keycloak 측에는 client `Backchannel logout URL` 설정이 존재(`KC-LOGOUT-C7`)하므로 **Keycloak 은 보낼 수 있으나 oauth2-proxy 가 받을 수 있는지가 미확인**. | `raw/official-docs/oauth2-proxy-endpoints-signout-official.md` §Usage Boundaries + `keycloak-oidc-logout-endpoint-official.md#KC-LOGOUT-C7` | back-channel logout 은 본 branch 에서 **채택하지 않음**(D6 은 RP-Initiated 방식). 다중 client SSO 요구가 생기면 재검토 — 그때 oauth2-proxy 수신 지원 여부부터 공식 확인. 커뮤니티 이슈(#1224)는 미지원을 시사하나 **이슈 트래커는 공식 근거 아님**. |
|
||||
| `WHITELIST_CVE_HISTORY` | `--whitelist-domain` 의 suffix 매칭에 과거 취약점 이력이 **있다고 전해짐 — 미검증(raw 미보존)**. WebSearch 로 식별자 존재만 확인했을 뿐 **GHSA/CVE 원문을 대조하지 않았다**: CVE-2021-21291(`.example.com` 등록 시 `badexample.com` 도 매칭됐다는 suffix 매칭 결함으로 *전해짐*), GHSA-j7px-6hwj-hpjg / GHSA-5m6c-jp6f-2vcv / GHSA-qqxw-m5fj-f7gv (open-redirect 우회로 *전해짐*). **위 ID·메커니즘은 인용이 아니라 후속 확인 대상이다.** | WebSearch 로 식별자 존재만 확인 — **raw 미보존, verbatim 미확보, 원문 미대조** | D7 을 "이 옵션을 켜면 open redirect 가 닫힌다"로 단정 금지. 버전 currency(수정 릴리스 이후 고정)가 defense-in-depth 로 필요. 정식 인용하려면 GHSA 페이지를 별도 `wiki-source-summarizer` 로 보존해야 함 — **본 라운드 미수행**. |
|
||||
| `SESSION_STORAGE_4KB_ABSENT` | session storage 공식 페이지에 `4k`/`4096`/`split` 문자열이 **0건** — 4kb cookie split 함정의 근거는 이 페이지가 아니라 **nginx 통합 페이지**([[raw/official-docs/oauth2-proxy-nginx-integration-official]], "Nginx normally only copies the first `Set-Cookie` header ... if your cookies are larger than 4kb, you will need to extract additional cookies manually")에 있다. | `raw/official-docs/oauth2-proxy-session-storage-official.md` §Usage Boundaries | D5a 의 Open Risk 에 반영 완료. 4kb 대응의 owner 는 [[raw/branch-notes/feature-keycloak-nginx-auth-request-integration]] D5. |
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 vendor docs 가 옵션의 존재와 형식을 증명해도 내 학습 시연에서의 정확한 동작은 별개. 다음은 P3A 또는 학습 시연 단계에서 실측해야 할 주장.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| `provider=keycloak-oidc` 와 `provider=oidc` (generic) 의 실질 차이는 group/role claim native 추출 여부 | `O2PK-C5` 는 `groups` client scope mapper 가 필요하다고 명시하지만, generic `oidc` provider 가 동일 mapping 으로 동작하는지의 비교 vendor doc 미확보 | 두 provider 로 동일 Keycloak realm 에 연결한 oauth2-proxy 컨테이너 2개 띄우고 `--allowed-group=/dev` 동작 비교 | `needs-confirmation` |
|
||||
| OIDC discovery (`.well-known/openid-configuration`) 호출 시점과 cache TTL | `O2PCOOKIE-C8`/`C9` (D9) 로 discovery **우회 방법**(`--skip-oidc-discovery` + 수동 endpoint 3종)은 확보했으나, discovery 를 *켰을 때* 언제 호출되는지(기동 1회 vs 주기적)와 JWKS cache TTL 은 공식 prose 문서에 서술 없음. 관련 플래그(`--oidc-jwks-cache-duration` 류)도 overview 페이지에서 미발견 → 소스코드(`providers/oidc.go`) 확인이 필요할 수 있음 | oauth2-proxy 시작 후 wireshark/tcpdump 로 discovery endpoint 호출 빈도 측정 | `needs-confirmation` |
|
||||
| **oauth2-proxy 기동이 Keycloak ready 에 의존하는지** (docker-compose 기동 순서 함정) | discovery 가 기동 시 issuer 에 도달해야 한다면, Keycloak 이 늦게 뜰 때 oauth2-proxy 가 죽는다. 공식 문서에 기동 순서 요구사항 서술 없음 — 커뮤니티 이슈에만 신호 존재(공식 근거 아님) | Keycloak 을 의도적으로 늦게 기동시킨 뒤 oauth2-proxy 컨테이너의 exit code / 재시도 로그 확인. 실패하면 게이팅으로 대응 — **메커니즘은 [[raw/branch-notes/feature-keycloak-docker-compose-stack]] `D3` 가 owner**(본 노트 재진술 금지) | `needs-confirmation` |
|
||||
| `--skip-jwt-bearer-tokens` 경로가 **로컬 JWKS 서명 검증**인지, 별도 network 검증을 수행하는지 (§Audit `NAMING_DRIFT`) | behaviour 페이지는 "opportunistically attempt to validate"(`O2PBEH-C1`) 로만 서술하고 메커니즘을 명시하지 않는다. overview 페이지의 `--extra-jwt-issuers` 설명이 `.well-known/jwks.json` 을 참조해 로컬 검증을 시사하지만 verbatim 확정은 아니다. 따라서 현재 명칭은 중립적인 "JWT bearer 검증 경로"로 한정한다. | Bearer JWT 요청 중 Keycloak `/protocol/openid-connect/token/introspect` 접근 로그와 JWKS 조회를 함께 관찰한다. Keycloak을 내린 상태에서 JWKS 캐시만으로 검증이 통과하는지도 확인한다. | `needs-confirmation` |
|
||||
| cookie session 모드 vs Redis session store 모드의 성능/운영 차이 | `O2PSESS-C1`~`C6` (D5a/D5b) 로 두 모드의 **메커니즘**(stateless cookie / ticket+SETEX)과 플래그는 확보. 그러나 P1A(Keycloak native user store, Google federation 없음) 에서 세션이 실제로 4kb 를 넘는지, Redis round-trip 이 latency 에 얼마나 기여하는지는 수치 미확보 — 4kb 초과 사례는 Azure federation 사례라 일반화 불가 | 로그인 후 브라우저 devtools 로 `_oauth2_proxy` cookie 실제 바이트 측정(4kb 대비) → 단일 oauth2-proxy 에 Redis backend 연결 후 cookie 크기 / login latency 비교 | `planned` |
|
||||
| RP-Initiated Logout 호출 시 Keycloak 세션이 실제로 종료되는지 | D6 은 `O2PE-C1`~`C4` + `KC-LOGOUT-C1`~`C6` 으로 **메커니즘 근거는 확보**(UNSUPPORTED 해소). 다만 공식 문서는 옵션·파라미터의 존재를 증명할 뿐 내 구성에서 세션이 실제로 끊기는지는 증명하지 않음 | logout 후 Keycloak admin console 의 active session 조회 + cookie 재제출 시 재로그인 강제 여부 확인 | `planned` |
|
||||
| **인가 거부 시 실제 응답 코드/본문** (401 vs 403 vs 로그인 루프) | D4 Open Risk 가 자인 — `O2PK-C3` 은 인가 실패 코드의 의미를 명시 안 하고, `O2PBEH-C2`~`C4` 는 authentication 단계 전용. [[raw/branch-notes/feature-keycloak-nginx-auth-request-integration]] D2의 `error_page` 분기가 이 값에 의존 | 허용 role 이 **없는** 사용자로 로그인 후 보호 경로 요청 → 응답 코드/본문 확인. `/oauth2/auth` subrequest 응답도 함께 확인(202/401 만 반환하는지 — `O2PE-C5` 와 대조) | `needs-confirmation` |
|
||||
| **인증이 강제된(=`--skip-auth-route` 아닌) 라우트에서 Bearer-only 요청이 session cookie 없이 통과하는지** | `O2PBEH-C1` 은 **스킵된 라우트** 전용 인용이고, 그 Does-not-prove 가 "강제 라우트에서 cookie/JWT 를 어떤 순서·우선순위로 시도하는지는 범위 밖" 이라 자인. §구현 가이드 3 의 통과 2행은 실패 경로의 대우에서 도출한 **추론**이지 공식 보장 아님 | 일반(비스킵) 경로에 `Authorization: Bearer <valid JWT>` 만 담아 요청 → cookie 없이 202/200 이 오는지 확인. 또는 `configuration/overview` 페이지를 별도 raw 로 보존해 verbatim 확정 | `needs-confirmation` |
|
||||
| **Keycloak 26.x 의 `end_session_endpoint` 실측값이 `/realms/{realm}/protocol/openid-connect/logout` 인지** | `KC-LOGOUT-C1` 의 evidence quote 전문은 "The logout endpoint logs out the authenticated user." 뿐 — **경로 문자열은 quote 에 없고** claim 서술문에만 있다. 하드코딩하면 Keycloak context-path 변경(D2 의 17+ 이슈)에 취약 | `curl https://<keycloak host>/realms/<realm>/.well-known/openid-configuration \| jq -r .end_session_endpoint` 로 실제 노출값 확인 → D6/§구현 가이드 2 의 3단계 경로와 대조 | `needs-confirmation` |
|
||||
| **`rd` 도메인이 `--whitelist-domain` 미등록일 때 logout 이 조용히 실패하는지** | `O2PE-C4` 가 "리다이렉트가 무시된다" 고 명시하나, 무시 시 사용자에게 보이는 최종 화면(에러 페이지 vs 기본 sign-out 페이지)은 서술 없음 — D6 의 가장 현실적인 실패 모드라 실측 가치 높음 | Keycloak 도메인을 `--whitelist-domain` 에서 **뺀 상태**로 logout 시도 → 최종 랜딩 화면 + Keycloak 세션 잔존 여부 확인 | `planned` |
|
||||
| `--whitelist-domain` 옵션이 open redirect 공격을 실제로 차단하는지 | `O2PCOOKIE-C7` (`raw/official-docs/oauth2-proxy-cookie-redirect-flags-official.md`) 로 옵션의 존재·문법(서브도메인 wildcard/포트 지정)은 확인됐으나, 미설정 시 기본 동작(전체 차단 여부)과 실제 공격 시나리오에서의 차단 여부는 공식 문서에 없음 | 공격 시나리오 (`rd=https://evil.example.com`) 로 redirect 시도 후 oauth2-proxy 응답 확인 | `planned` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 아직 없음(문서 단계).
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/keycloak-oidc-logout-endpoint-official]]
|
||||
- [[raw/official-docs/oauth2-proxy-behaviour-cookie-vs-bearer-official]]
|
||||
- [[raw/official-docs/oauth2-proxy-cookie-redirect-flags-official]]
|
||||
- [[raw/official-docs/oauth2-proxy-endpoints-official]]
|
||||
- [[raw/official-docs/oauth2-proxy-endpoints-signout-official]]
|
||||
- [[raw/official-docs/oauth2-proxy-keycloak-oidc-provider-official]]
|
||||
- [[raw/official-docs/oauth2-proxy-nginx-integration-official]]
|
||||
- [[raw/official-docs/oauth2-proxy-overview-config-official]]
|
||||
- [[raw/official-docs/oauth2-proxy-session-storage-official]]
|
||||
- [[raw/official-docs/security-jwt-rfc-7519-validation]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
> 본 sub-sub-branch 는 leaf — 자식 자료 없음. Phase 3 P3A 실 구현 또는 외부 산출물 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 근거 자료
|
||||
|
||||
- [[raw/official-docs/oauth2-proxy-overview-config-official]]
|
||||
- [[raw/official-docs/oauth2-proxy-keycloak-oidc-provider-official]]
|
||||
- [[raw/official-docs/oauth2-proxy-cookie-redirect-flags-official]] — D5c (cookie 속성값 표준화: samesite/secure/secret/expire/refresh) + `--whitelist-domain` + `--skip-oidc-discovery`
|
||||
- [[raw/official-docs/oauth2-proxy-session-storage-official]] — D5a/D5b (session storage 백엔드: cookie vs redis)
|
||||
- [[raw/official-docs/oauth2-proxy-endpoints-signout-official]]
|
||||
- [[raw/official-docs/oauth2-proxy-behaviour-cookie-vs-bearer-official]]
|
||||
- [[raw/official-docs/keycloak-oidc-logout-endpoint-official]] — D6 (RP-Initiated Logout) Keycloak 측 근거: `end_session_endpoint`/`id_token_hint`/`post_logout_redirect_uri`/Backchannel Logout URL
|
||||
|
||||
### 오류 기록 (이 sub-sub-branch 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 documented-only 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — Phase 3 실 구현 단계에 누적)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 본 sub-sub-branch는 **문서까지만**. wiki 추출은 root branch의 비교 매트릭스 시점에 일괄 처리.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: 해당 없음 (문서 단계)
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목: 없음
|
||||
- `locally-verified` 항목: 없음
|
||||
- `prod-verified` 항목: 없음
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
- 본 sub-sub-branch 전체가 `documented-only` 등급. P1A 부모 sub-branch와 함께 추후 비교 매트릭스 / `wiki/concepts/keycloak-deployment-patterns.md`에 인용되는 형태로만 활용. `wiki/projects/` 직접 승급 없음.
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
---
|
||||
title: branch / feature-keycloak-patterns (root, 작업 인덱스)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-PATTERNS-OVERVIEW-020
|
||||
kind: project-work-item
|
||||
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-patterns
|
||||
parent_branch:
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, oauth2, oidc, auth]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 6855baa10b305d5b251f64bfec1f707854488bd72d631b0f9968cfcaaf1f8981
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-patterns (root)
|
||||
|
||||
> Layer: `raw/branch-notes/` — **작업 진행 인덱스**. 프로젝트 정의·6 패턴 분류·공통 컴포넌트는 [[raw/project-notes/keycloak-patterns-overview]] 참조.
|
||||
> 본 root는 sub-branch 진행 상태와 일정만 추적.
|
||||
> `status_label`: `in-progress`
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/project-notes/keycloak-patterns-overview]]
|
||||
|
||||
<!-- 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 변형이다 | AP1~AP4 taxonomy와 child progress index를 유지하는 governance hub에 적용한다 | [[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 -->
|
||||
|
||||
## 프로젝트 SSOT
|
||||
|
||||
- **canonical SSOT**: [[raw/project-notes/keycloak-patterns-overview]] — 프로젝트 정의 / 6 패턴 분류 / 공통 컴포넌트 / 인프라 / 본인 작업 / 트러블슈팅 / 자신 없는 부분 / 진행 단계.
|
||||
- **사용자 본인 인프라 개요**: [[raw/project-notes/project-infra-overview]] (sister project note)
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
본 root branch는 keycloak-patterns 프로젝트의 **작업 진행 인덱스** 역할. 면접에서 "왜 이 배치를 택했나" / "Google 로그인이 붙으면 흐름이 어떻게 바뀌나" / "BFF vs SPA Direct OIDC trade-off는?"에 자신 있게 답할 수 있는 수준의 6 패턴 이해 + P3A 한정 실 구현이 최종 목표 (canonical SSOT 참조).
|
||||
|
||||
본 root 자체의 책무:
|
||||
- 6 sub-branch + 27 sub-sub-branch 진행 상태 추적
|
||||
- 외부 근거 raw 보존 인덱스
|
||||
- 머지 후 wiki 추출 시 비교 매트릭스 산출
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- (본문 해당 섹션에서 다룬 항목 참조)
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- (명시 필요)
|
||||
|
||||
## 근거 (root는 hub 역할이라 자체 인용은 적고, 개별 결정 근거는 각 sub-branch의 Sources 표 참조)
|
||||
|
||||
개별 패턴별 근거는 sub-branch (feature-keycloak-edge-forwardauth-no-google ~ -6) 의 Sources 표에 위임.
|
||||
|
||||
## 6 sub-branch + 27 sub-sub-branch 진행 인덱스
|
||||
|
||||
> **⚠️ 갱신 (2026-07-14)**: 아래 6패턴 인덱스는 **Phase 0 legacy(배치×federation 축)**. 현 실행계획 SSOT 는 [[raw/project-notes/keycloak-patterns-overview]] 의 **§Branch 분해 / 실행계획(R4)** — 인증 아키텍처 4패턴(AP1~AP4) + 19 Tier-2. 신규 작업은 hub 분해표를 따르며, 아래 슬러그는 hub §2.3 매핑대로 AP 로 re-map 대상. D2(`-{N}-{M}` numbered 명명)는 CLAUDE.md §11 위반으로 폐기(각 sub-sub 는 이미 content-descriptive 슬러그라 실제 영향은 프레이밍뿐).
|
||||
|
||||
총 34 branch-notes (root 1 + sub 6 + sub-sub 27). 모두 `documented-only` / `planned` (P3A만 실 구현 대상).
|
||||
|
||||
### P1A — Edge Forward Auth (no Google) — [[raw/branch-notes/feature-keycloak-edge-forwardauth-no-google]]
|
||||
|
||||
- 외부 근거 raw 5개 / sub-sub 4개
|
||||
- [[raw/branch-notes/feature-keycloak-oauth2-proxy-oidc-flow]] — oauth2-proxy 구성과 OIDC 흐름
|
||||
- [[raw/branch-notes/feature-keycloak-nginx-auth-request-integration]] — nginx auth_request 통합 (4kb cookie 함정)
|
||||
- [[raw/branch-notes/feature-keycloak-header-spoofing-defense]] — 헤더 spoofing 방어 (NetworkPolicy / SG / mTLS)
|
||||
- [[raw/branch-notes/feature-keycloak-traefik-forwardauth-alternative]] — Traefik ForwardAuth 대안 비교
|
||||
|
||||
### P1B — Edge + Google IdP Brokering — [[raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation]]
|
||||
|
||||
- 외부 근거 raw 5개 / sub-sub 4개
|
||||
- [[raw/branch-notes/feature-keycloak-idp-brokering-google-client]] — Keycloak IdP brokering 구성 (Google client 등록)
|
||||
- [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] — First Broker Login Flow (Confirm Link Existing Account)
|
||||
- [[raw/branch-notes/feature-keycloak-google-claim-attribute-mapping]] — Google claim → Keycloak attribute mapping
|
||||
- [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] — Account Linking 보안 (`sub` vs `email`)
|
||||
|
||||
### P2A — Internal SPA + Resource Server (no Google) — [[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]]
|
||||
|
||||
- 외부 근거 raw 6개 / sub-sub 5개
|
||||
- [[raw/branch-notes/feature-keycloak-pkce-flow-stages]] — PKCE 4단계 (verifier/challenge/auth/exchange)
|
||||
- [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] — Spring Security Resource Server + audience validator
|
||||
- [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] — Token 저장 위치 trade-off (localStorage/cookie/memory)
|
||||
- [[raw/branch-notes/feature-keycloak-bff-vs-spa-direct]] — BFF 대안 비교
|
||||
- [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] — Refresh token rotation + revocation
|
||||
|
||||
### P2B — Internal + Google federation — [[raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation]]
|
||||
|
||||
- 외부 근거 raw 4개 / sub-sub 4개
|
||||
- [[raw/branch-notes/feature-keycloak-federation-spa-zero-change]] — SPA 코드 변경 없음 검증 (P2A → P2B 전환)
|
||||
- [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]] — IdP Mappers (Google claim → Keycloak role)
|
||||
- [[raw/branch-notes/feature-keycloak-three-leg-trust-chain]] — 3-leg trust chain 검증
|
||||
- [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]] — Account Linking SPA 컨텍스트
|
||||
|
||||
### **P3A — Single EC2 (실 구현 대상)** — [[raw/branch-notes/feature-keycloak-single-ec2-no-google]]
|
||||
|
||||
- 외부 근거 raw 4개 / sub-sub 6개 (각 sub-sub는 실 구현 plan 포함)
|
||||
- [[raw/branch-notes/feature-keycloak-docker-compose-stack]] — docker-compose 환경 구성
|
||||
- [[raw/branch-notes/feature-keycloak-realm-client-export]] — Keycloak realm/client 설정 + JSON export
|
||||
- [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] — Spring Boot Resource Server + audience validator
|
||||
- [[raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce]] — vanilla JS SPA (Authorization Code + PKCE)
|
||||
- [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] — iss claim mismatch 함정 + KC_HOSTNAME 해결
|
||||
- [[raw/branch-notes/feature-keycloak-refresh-rotation-and-logout]] — refresh token rotation + 로그아웃 흐름
|
||||
|
||||
### P3B — Single EC2 + Google federation — [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]]
|
||||
|
||||
- 외부 근거 raw 4개 / sub-sub 4개
|
||||
- [[raw/branch-notes/feature-keycloak-public-domain-tunneling]] — public 도메인 확보 (ngrok / Cloudflare Tunnel)
|
||||
- [[raw/branch-notes/feature-keycloak-reverse-proxy-headers]] — Keycloak reverse proxy 설정 (KC_PROXY_HEADERS + KC_HOSTNAME)
|
||||
- [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]] — Google OAuth client 등록 + redirect_uri 갱신
|
||||
- [[raw/branch-notes/feature-keycloak-https-termination-caddy-nginx]] — HTTPS termination (Caddy vs nginx+certbot vs Cloudflare Tunnel)
|
||||
|
||||
## TODO
|
||||
|
||||
- [x] 6 sub-branch 1차 작성 (목표 / 다이어그램 / 토큰 sequence / 장단점) — 등급: `documented-only`
|
||||
- [x] 28 외부 근거 raw 보존 — 등급: `documented-only`
|
||||
- [x] [[raw/project-notes/keycloak-patterns-overview]] 신설 (메인 SSOT, 2026-05-25) — 등급: `documented-only`
|
||||
- [ ] 6 sub-branch 외부 근거 섹션 강화 (채택 결정 / 검토 대안 / 비교 핵심 구조) — 등급: `planned`
|
||||
- [ ] 각 sub-branch 별 sub-sub-branch (세부 학습/구현 단계) 추가 — 등급: `planned`
|
||||
- [ ] P3A 실 구현 (`/home/donghyeon/workspace/keycloak-patterns/`) — 등급: `planned`
|
||||
- [ ] 6 패턴 trade-off 매트릭스 통합 문서 (Phase 4) — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- root branch-note 슬림화: 프로젝트 정의는 [[raw/project-notes/keycloak-patterns-overview]]로 이전 (2026-05-25). root는 작업 인덱스만 유지.
|
||||
- 외부 근거 구조 강화 후속 작업: ca-tmpl branch-notes와 동일하게 "채택 결정 / 검토 대안 / 비교 핵심" 3단 구조로 재작성.
|
||||
- **역사 기록(폐기됨)**: 초기에는 `feature-keycloak-patterns-{N}-{M}` numbered hierarchy를 제안했으나 현 규칙과 충돌해 사용하지 않는다. 현재 규약은 구현 내용을 드러내는 4~8단어 영문 kebab-case slug이며 계층은 `parent_branch`와 `## Parent`로만 표현한다.
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- **D1** 2026-05-25: 프로젝트 정의는 `raw/project-notes/`에, 작업 진행은 `raw/branch-notes/`에. ca-tmpl과 동일 위계.
|
||||
- **D2 (Historical / superseded — DO NOT USE)** 2026-05-25: sub-sub-branch를 `-{N}-{M}` dash-숫자로 명명하자는 초기 결정. 현 `CLAUDE.md` §11과 `rules/naming-conventions.md`에 의해 폐기되었으며, 구현 내용을 드러내는 4~8단어 영문 kebab-case slug가 현행 결정이다.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 본 root branch 는 hub 역할 — 자체 결정은 **운영 / 조직 규약** 만 다루고, 패턴 채택 결정은 sub-branch 로 위임됨. 따라서 본 hub 의 결정은 외부 raw source 가 아닌 **프로젝트 내부 규약 (CLAUDE.md / rules/) + ca-tmpl 선례** 에 근거함 → 외부 raw claim 측면에서는 모두 UNSUPPORTED_DECISION.
|
||||
|
||||
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|
|
||||
| D1 | 프로젝트 정의는 `raw/project-notes/`, 작업 진행은 `raw/branch-notes/` 분리 (ca-tmpl 과 동일 위계) | UNSUPPORTED_DECISION (외부 raw source 없음 — 내부 규약 `rules/linking-rules.md` §12 named hub 패턴 + `CLAUDE.md` §2 디렉터리 역할 + ca-tmpl 선례에 근거) | `internal-convention` | 외부 표준 근거 없음 — 다른 wiki / KMS 패턴과 비교 평가 미수행. 단 본 프로젝트 단일 vault 내 일관성은 충분 |
|
||||
| D2 | **RETIRED / superseded** — `-{N}-{M}` numbered hierarchy는 사용하지 않는다. 현행 규약은 구현 내용을 드러내는 4~8단어 영문 kebab-case slug이고 계층은 `parent_branch` + `## Parent`로만 표현한다. | `CLAUDE.md` §11 + `rules/naming-conventions.md` §2.1.2~§2.1.6 | `internal-convention` | 기존 파일·링크에 남은 numbered slug는 별도 migration 계획으로 정리하되 신규 문서에서는 생성 금지 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> **Trace**: D1(프로젝트 정의와 진행 노트 분리)과 D2(내용 기반 slug + frontmatter 계층)를 따른다.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: hub의 수기 인덱스 갱신 방식은 외부 raw source가 정하지 않는 vault 운영 선택이다. 본 hub에는 class/config/API 명세를 두지 않고, child owner의 진행 상태와 링크만 유지한다.
|
||||
|
||||
- [[raw/branch-notes/feature-keycloak-edge-forwardauth-no-google]] — P1A 작업 묶음 owner; 구현·설정 세부는 child에서만 유지한다.
|
||||
- [[raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation]] — P1B 작업 묶음 owner; 구현·설정 세부는 child에서만 유지한다.
|
||||
- [[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]] — P2A 작업 묶음 owner; 구현·설정 세부는 child에서만 유지한다.
|
||||
- [[raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation]] — P2B 작업 묶음 owner; 구현·설정 세부는 child에서만 유지한다.
|
||||
- [[raw/branch-notes/feature-keycloak-single-ec2-no-google]] — P3A 구현 owner; hub는 증거 등급과 완료 상태만 반영한다.
|
||||
- [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]] — P3B 문서 작업 owner; hub는 증거 등급과 완료 상태만 반영한다.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**: 수기 인덱스가 실제 파일·`parent_branch`와 어긋나면 진행률과 owner 탐색이 stale해진다. 아래 Claims To Verify의 파일·frontmatter 대조를 통과한 뒤에만 개수를 갱신한다.
|
||||
- **다른 계약 의존**: [[raw/project-notes/keycloak-patterns-overview]]가 실행계획과 패턴 분류를 소유한다. 본 hub는 그 내용을 재진술하지 않고 위 child owner 링크와 상태만 소비한다.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> root branch 는 hub 인덱스이므로 자체 verification 보다는 sub-branch 의 결정 / 구현이 정확한지에 대한 메타 검증 항목 위주.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 6 sub-branch + 27 sub-sub-branch 진행 인덱스가 실제 파일과 일치 | 본 root 의 인덱스는 수기 유지, drift 가능 | `ls raw/branch-notes/feature-keycloak-*` + `grep parent_branch:` 와 본 §6 sub-branch 인덱스 cross-check | `needs-confirmation` |
|
||||
| 폐기된 numbered slug가 기존 파일·링크에 남아 있는지 | D2는 폐기됐지만 역사적으로 생성된 경로가 있을 수 있어 일괄 rename 시 링크 파손 위험이 있음 | `rules/naming-conventions.md` 기준으로 기존 slug를 inventory하고, 역링크를 포함한 별도 migration plan에서 단계적으로 정리 | `planned` |
|
||||
| P3A 한정 실 구현 → wiki/projects/ 승급 가능한 verified 항목이 실제로 생성됨 | 현재 모두 `documented-only` / `planned` | Phase 3 완료 후 [[raw/branch-notes/feature-keycloak-single-ec2-no-google]] 의 TODO 항목별 `actually-implemented` / `locally-verified` 등급 부여 + 측정 evidence 첨부 | `planned` |
|
||||
| 패턴별 외부 근거 raw 자료가 모두 `## Claims Extracted` + `## Usage Boundaries` 구조를 갖춤 | claim traceability 정책이 2026-05-27 도입 — 기존 raw 는 migration 대상 | `grep -L "## Claims Extracted" raw/official-docs/keycloak*` + `raw/company-tech-blogs/keycloak*` | `needs-confirmation` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 아직 없음(문서 단계).
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/company-tech-blogs/curity-bff-pattern-spa]]
|
||||
- [[raw/company-tech-blogs/keycloak-google-login-codemancers]]
|
||||
- [[raw/official-docs/cloudflare-tunnel-routing-official]]
|
||||
- [[raw/official-docs/google-oauth2-redirect-uri-validation-official]]
|
||||
- [[raw/official-docs/google-openid-connect-oidc]]
|
||||
- [[raw/official-docs/keycloak-first-broker-login-flow]]
|
||||
- [[raw/official-docs/keycloak-first-login-flow]]
|
||||
- [[raw/official-docs/keycloak-getting-started-docker]]
|
||||
- [[raw/official-docs/keycloak-google-idp-setup]]
|
||||
- [[raw/official-docs/keycloak-hostname-configuration]]
|
||||
- [[raw/official-docs/keycloak-identity-brokering-overview-official]]
|
||||
- [[raw/official-docs/keycloak-reverseproxy-official]]
|
||||
- [[raw/official-docs/keycloak-securing-apps-overview-official]]
|
||||
- [[raw/official-docs/keycloak-server-containers-docker]]
|
||||
- [[raw/official-docs/nginx-auth-request-module-official]]
|
||||
- [[raw/official-docs/ngrok-http-tunnel-official]]
|
||||
- [[raw/official-docs/oauth-v2-1-draft-ietf]]
|
||||
- [[raw/official-docs/oauth2-pkce-rfc-7636]]
|
||||
- [[raw/official-docs/oauth2-proxy-keycloak-oidc-provider-official]]
|
||||
- [[raw/official-docs/oauth2-proxy-nginx-integration-official]]
|
||||
- [[raw/official-docs/oauth2-proxy-overview-config-official]]
|
||||
- [[raw/official-docs/oidc-client-ts-library]]
|
||||
- [[raw/official-docs/owasp-html5-storage-xss-spa]]
|
||||
- [[raw/official-docs/spring-security-resource-server-jwt]]
|
||||
- [[raw/official-docs/traefik-forwardauth-middleware-official]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
- [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]]
|
||||
- [[raw/branch-notes/feature-keycloak-bff-vs-spa-direct]]
|
||||
- [[raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation]]
|
||||
- [[raw/branch-notes/feature-keycloak-edge-forwardauth-no-google]]
|
||||
- [[raw/branch-notes/feature-keycloak-federation-spa-zero-change]]
|
||||
- [[raw/branch-notes/feature-keycloak-https-termination-caddy-nginx]]
|
||||
- [[raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation]]
|
||||
- [[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]]
|
||||
- [[raw/branch-notes/feature-keycloak-pkce-flow-stages]]
|
||||
- [[raw/branch-notes/feature-keycloak-public-domain-tunneling]]
|
||||
- [[raw/branch-notes/feature-keycloak-reverse-proxy-headers]]
|
||||
- [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]]
|
||||
- [[raw/branch-notes/feature-keycloak-single-ec2-no-google]]
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
> 본 root는 6 sub-branch hub. 위 "6 sub-branch + 27 sub-sub-branch 진행 인덱스" 섹션과 중복 정보이나, `templates/linking-rules.md` §4 양방향 작성 패턴에 따라 카테고리별 명시.
|
||||
|
||||
### Sub-branches (6 패턴별 hub)
|
||||
|
||||
- [[raw/branch-notes/feature-keycloak-edge-forwardauth-no-google]] — P1A Edge / Ingress (no Google)
|
||||
- [[raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation]] — P1B Edge / Ingress + Google federation
|
||||
- [[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]] — P2A Cluster-internal SPA-direct (no Google)
|
||||
- [[raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation]] — P2B Cluster-internal + Google federation
|
||||
- [[raw/branch-notes/feature-keycloak-single-ec2-no-google]] — P3A Single EC2 (no Google) **— vanilla JS 실 구현 대상**
|
||||
- [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]] — P3B Single EC2 + Google federation
|
||||
|
||||
### 근거 자료
|
||||
|
||||
- (패턴별 official-docs / company-tech-blogs 는 sub-branch 의 Sources 표에서 cited)
|
||||
|
||||
### 오류 기록
|
||||
|
||||
- (없음 — Phase 3 P3A 실 구현 진입 시 발생 예상)
|
||||
|
||||
### 면접 준비
|
||||
|
||||
- (없음 — 패턴별 면접 후보는 sub-branch Cluster의 Interview prep 항목 참조)
|
||||
|
||||
### 강의
|
||||
|
||||
- (없음)
|
||||
|
||||
### Blog drafts / job-posting tie-ins
|
||||
|
||||
- (없음)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: P3A 한정 로컬 검증 예정
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목: (Phase 3 완료 후 채움)
|
||||
- `locally-verified` 항목: (Phase 3 완료 후 채움)
|
||||
- `prod-verified` 항목: (없음, prod 배포 out of scope)
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned): P1A/P1B/P2A/P2B/P3B 5개 패턴은 문서까지만.
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
---
|
||||
title: branch / feature-keycloak-pkce-flow-stages (PKCE 4단계 — verifier/challenge/auth/exchange)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-CHILD-B7701136
|
||||
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-pkce-flow-stages
|
||||
parent_branch: feature-keycloak-patterns
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, p2a, pkce, oauth2, rfc-7636, spa]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 018199eabc07fd85c9fcf91fdfe596cec5c1264b6797f184b65568c51f8896bc
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-pkce-flow-stages — PKCE 단계별 (code_verifier
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/branch-notes/feature-keycloak-patterns]] governance Work Item의 child. P2A 의미 계약은 [[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]]를 참조한다.
|
||||
> **목적**: RFC 7636 PKCE의 4단계를 입력/출력/보안 의미까지 단계별로 정확히 설명할 수 있게 한다.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
|
||||
<!-- 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 변형이다 | AP1 SPA-direct 변형의 PKCE 단계별 계약에 적용한다 | [[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 -->
|
||||
## 목표
|
||||
|
||||
P2A는 SPA(public client)가 client secret을 보관할 수 없으므로 authorization code 탈취 시 누구나 토큰을 받아낼 수 있다. PKCE는 code-to-token 단계에서 **이 코드를 발급받은 동일 클라이언트만 토큰을 받을 수 있도록** `code_verifier`/`code_challenge` 바인딩을 추가하는 메커니즘이다.
|
||||
|
||||
핵심 질문:
|
||||
|
||||
- `code_verifier` 형식은 왜 43~128자 unreserved character로 제한되는가? (entropy 보장 + URL-safe)
|
||||
- `S256`과 `plain`의 차이는? 왜 OAuth 2.1은 `S256`을 강제하는가?
|
||||
- `state` / `nonce`는 PKCE와 어떻게 다른 역할인가?
|
||||
- `code_verifier`가 localStorage에 노출되면 PKCE는 어떤 의미인가? (= 거의 무의미)
|
||||
|
||||
본 sub-sub-branch는 **각 단계의 입력/출력/공격 모델/방어 효과**를 한 줄씩 정리한다.
|
||||
|
||||
- 이슈: (학습 노트, 이슈 없음)
|
||||
- PR: (구현 없음)
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- (본문 해당 섹션에서 다룬 항목 참조)
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- (명시 필요)
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
- [[raw/official-docs/oauth2-pkce-rfc-7636]] — RFC 7636 PKCE 본문 (verifier/challenge 정의, S256 / plain)
|
||||
- [[raw/official-docs/oauth-v2-1-draft-ietf]] — OAuth 2.1 draft (PKCE mandatory, S256 강제)
|
||||
- [[raw/official-docs/keycloak-securing-apps-overview-official]] — Keycloak SPA client 설정
|
||||
- [[raw/official-docs/keycloak-client-pkce-method-enforcement-official]] — Keycloak "PKCE method" Admin UI 옵션 (Capability Config) — D1/D5 관련 UI 라벨 정정 근거
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [ ] **Step 1: code_verifier 생성** — 등급: `documented-only`
|
||||
- 형식: 43~128 chars, unreserved = `[A-Z] [a-z] [0-9] - . _ ~` (RFC 7636 §4.1)
|
||||
- entropy: 최소 256 bits 권장 (`crypto.getRandomValues(32 bytes)` → base64url)
|
||||
- 저장 위치: **메모리 또는 sessionStorage**. localStorage 절대 금지 (XSS 노출 시 PKCE 무력화)
|
||||
- [ ] **Step 2: code_challenge 계산** — 등급: `documented-only`
|
||||
- `code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))` (S256)
|
||||
- `S256` vs `plain`: `plain`은 challenge = verifier (해시 안 함). MITM이 challenge만 보고 verifier 추론 가능 → **OAuth 2.1은 S256 강제**
|
||||
- Keycloak client 설정: Capability config의 `PKCE method = S256` 지정
|
||||
- [ ] **Step 3: Authorization Request (`/auth`)** — 등급: `documented-only`
|
||||
- 추가 파라미터: `code_challenge`, `code_challenge_method=S256`, `state`, `nonce`
|
||||
- `state`: CSRF 방지 (redirect 응답이 본인이 시작한 것인지 확인)
|
||||
- `nonce`: ID token replay 방지 (OIDC 한정, OAuth2만이면 불필요)
|
||||
- 입력: client_id / redirect_uri / scope / state / code_challenge / method
|
||||
- 출력: redirect with `?code=<auth_code>&state=<echo>`
|
||||
- [ ] **Step 4: Token Request (`/token` exchange)** — 등급: `documented-only`
|
||||
- 입력: `grant_type=authorization_code` + `code` + `redirect_uri` + `client_id` + `code_verifier`
|
||||
- Keycloak 측 검증: `SHA256(verifier) == 저장된 challenge` 비교
|
||||
- 출력: `access_token` / `id_token` / `refresh_token` / `expires_in`
|
||||
- 실패 시: `invalid_grant` 응답
|
||||
- [ ] **만료 / 재시도 시나리오** — 등급: `documented-only`
|
||||
- `code` TTL: Keycloak 기본 60s. 만료 시 `/auth`부터 재요청 (verifier도 새로 생성)
|
||||
- 재사용: authorization code는 **1회용**. 같은 code로 두 번 `/token` 호출 시 두 번째는 거부 + 발급된 토큰 invalidate (RFC 6749 §4.1.2)
|
||||
- [ ] **함정 정리표** — 등급: `documented-only`
|
||||
- verifier를 localStorage에 → XSS로 탈취 → PKCE 무의미
|
||||
- challenge_method 누락 → Keycloak이 `plain`으로 fallback → S256 강제 설정 필요
|
||||
- state 검증 누락 → CSRF로 공격자 코드 주입 가능
|
||||
- redirect_uri exact match 누락 → open redirect 공격
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
작업하며 떠오른 메모. 자유 형식.
|
||||
|
||||
- `code_verifier` length 43은 base64url(32 bytes) 결과 길이와 일치. 보통 32 random bytes로 생성하면 OK.
|
||||
- 대상 Keycloak UI에서는 Capability config의 `PKCE method`를 `S256`으로 지정한다. 버전별 UI 차이는 생성된 realm export의 client 설정과 함께 대조하며, 미설정 시 실제 허용 동작은 실측 전까지 단정하지 않는다.
|
||||
- `state` random 값은 PKCE와 독립. PKCE = code↔token 바인딩, state = response↔request 바인딩.
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
> 추후 면접/회고에서 "왜 이렇게 했나" 답할 근거. 대안과 함께 기록.
|
||||
|
||||
- 2026-05-25: PKCE method는 **`S256`만** 정리 대상. `plain`은 OAuth 2.1에서 사실상 deprecated이므로 비교용 1줄 언급만.
|
||||
- 2026-05-25: `code_verifier` 저장 위치는 **메모리 또는 sessionStorage** 권장으로 기록. localStorage는 위험성 명시.
|
||||
- 2026-05-25: 본 sub-sub-branch는 PKCE 4단계 자체에 집중. token 저장은 [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]]에서.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시한다.
|
||||
> `Decision ID` 는 이 branch-note 안에서 안정적으로 유지한다.
|
||||
> `Supporting Claims` 는 `raw/<category>/<slug>.md#<CLAIM-ID>` 형식.
|
||||
|
||||
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|
|
||||
| D1 | PKCE method = `S256` 만 정리 대상 (`plain` 은 비교용 1줄). Keycloak client 설정에서 PKCE method 옵션(정식 UI 라벨 "PKCE method")을 S256 으로 지정 | `raw/official-docs/oauth2-pkce-rfc-7636.md#PKCE-RFC7636-C3` (S256 공식: `BASE64URL-ENCODE(SHA256(ASCII(verifier)))`), `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C1` (OAuth 2.1: "Clients MUST use code_challenge and code_verifier ..."), `raw/official-docs/keycloak-client-pkce-method-enforcement-official.md#KC-PKCE-C1` (Admin UI 옵션 정식 명칭·위치), `#KC-PKCE-C3` (S256 선택 시 서술) | `official-standard + official-standard + official-vendor-doc` | OA21-C1 은 PKCE 사용 자체를 MUST 로 강제하지만 "S256 강제 / plain 금지" 라는 정확한 문장은 OA21-C1 인용에 포함 안 됨 — §7.5.1 예외 조건 확인 필요. 단, RFC 7636 + OAuth 2.1 종합 권고로 보면 정당. **2026-07-17 업데이트**: `KC-PKCE-C1` 이 UI 라벨 오류를 정정("Proof Key for Code Exchange Code Challenge Method" 가 아니라 "PKCE method", Capability Config 섹션)했으나, `KC-PKCE-C3` 은 "Keycloak applies... S256" 이라고만 서술 — **S256 설정 시 `code_challenge_method=plain` 요청을 실제로 거부(reject)한다는 명시적 문장은 여전히 없음**. 아래 Claims To Verify 의 "plain 메서드 요청을 거부" 항목은 `needs-confirmation` 유지 |
|
||||
| D2 | `code_verifier` 저장 위치 = 메모리 또는 sessionStorage 권장 (localStorage 금지) | UNSUPPORTED_DECISION | — | 본 branch 의 Sources (RFC 7636, OAuth 2.1 draft, Keycloak securing-apps) 어느 곳도 localStorage vs sessionStorage 의 XSS 노출 차이를 직접 다루지 않음. OWASP XSS 가이드 / RFC 9700 (OAuth 2.0 Security BCP) 추가 필요 |
|
||||
| D3 | 본 sub-sub-branch 는 PKCE 4단계 자체에 집중 (token 저장은 sibling branch 분리) | (스코프 결정 — 단일 source claim 으로 정당화 불필요) | N/A (scope decision) | scope 분리 자체는 evidence-based 가 아닌 작업 구조 결정 |
|
||||
| D4 (TODO 표 step 1) | code_verifier 형식 43~128 chars unreserved, entropy 256 bits 권장 | `raw/official-docs/oauth2-pkce-rfc-7636.md#PKCE-RFC7636-C2` (verifier/challenge 생성 정의) | `official-standard` (간접 — RFC §4.1 구체 spec 은 본 branch Sources 의 verbatim 인용 표에 미포함, raw 의 "Usage Boundaries" 가 §4.1 추가 발췌 필요로 명시) | RFC 7636 §4.1 의 정확한 character set/length 는 PKCE-RFC7636-C1~C5 verbatim 인용에 직접 포함 안 됨 — raw 의 "Usage Boundaries" 와 "메모" 가 이 한계를 명시. 별도 §4.1 발췌 추가 권장 |
|
||||
| D5 (TODO 표 step 2) | S256 공식 `code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))` + 대상 Keycloak Capability config의 `PKCE method = S256` 지정 | `raw/official-docs/oauth2-pkce-rfc-7636.md#PKCE-RFC7636-C3` (S256 공식), `raw/official-docs/keycloak-client-pkce-method-enforcement-official.md#KC-PKCE-C1`, `#KC-PKCE-C3` (대상 UI 라벨·S256 설정) | `official-standard + official-vendor-doc` | Keycloak 버전별 UI 라벨·내부 JSON key 차이는 생성된 realm export와 대조 필요. S256 설정 시 `plain` 또는 PKCE 없는 요청의 실제 거부 응답도 `needs-confirmation` |
|
||||
| D6 (TODO 표 step 4) | Token exchange 시 Keycloak 이 `SHA256(verifier) == 저장된 challenge` 비교 후 실패 시 `invalid_grant` | `raw/official-docs/oauth2-pkce-rfc-7636.md#PKCE-RFC7636-C4` (server 가 verifier 변환 후 challenge 와 비교, 불일치 시 access 거부) | `official-standard` | PKCE-RFC7636-C4 의 "Does not prove" 가 명시: 거부 응답의 정확한 error code / HTTP status 는 본 인용 범위 밖. `invalid_grant` 매핑은 RFC 6749 영역 (별도 raw 필요) |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
### 1. PKCE transaction stage 계약
|
||||
|
||||
> **Trace**: D4 + `PKCE-RFC7636-C2`, D5 + `PKCE-RFC7636-C3` / `KC-PKCE-C1` / `KC-PKCE-C3`, D6 + `PKCE-RFC7636-C4`만 구현 근거로 사용한다.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: D2의 verifier 저장 위치 선택은 현재 외부 claim이 없다. 이 branch에서 새 storage policy를 구현 명세로 고정하지 않고, [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] D1 — token custody owner를 따른다.
|
||||
|
||||
| Stage | 입력 → 출력 | 구현·검증 경계 | Trace |
|
||||
|---|---|---|---|
|
||||
| verifier 생성 | CSPRNG 입력 → transaction별 `code_verifier` | 43~128자·character set은 D4의 간접 근거 한계를 유지하고, RFC §4.1 직접 claim 보강 전에는 `documented-only`다. | D4 / `PKCE-RFC7636-C2` |
|
||||
| challenge + authorization | verifier → S256 challenge와 authorization request | D5의 공식으로 challenge를 계산하고 대상 Keycloak UI의 `PKCE method`를 S256으로 설정한다. `plain`/무-PKCE 요청의 실제 거부는 실측 전 단정하지 않는다. | D5 / `PKCE-RFC7636-C3`, `KC-PKCE-C1`, `KC-PKCE-C3` |
|
||||
| token exchange | authorization code + 동일 verifier → token 또는 access 거부 | server-side 변환값 불일치를 거부하는 것까지만 단언한다. 정확한 Keycloak error code·HTTP status는 별도 검증 결과로 채운다. | D6 / `PKCE-RFC7636-C4` |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
- **실패·엣지 경로**: verifier 불일치 시 access를 거부해야 한다(D6). `invalid_grant`와 HTTP status는 현재 source 범위 밖이므로 test expected value를 고정하기 전에 dev Keycloak 응답을 캡처한다.
|
||||
- **실패·엣지 경로**: Keycloak의 `PKCE method = S256` 설정이 `plain` 또는 PKCE 없는 요청을 실제로 거부하는지는 `needs-confirmation`이다. 설정 전후 realm export와 token exchange 응답을 함께 대조한다.
|
||||
- **실패·엣지 경로**: authorization code TTL 60초·재사용 시 기존 token 무효화는 현재 근거가 부족하다. 아래 Claims To Verify가 닫힐 때까지 구현 상수나 확정 동작으로 승격하지 않는다.
|
||||
- **다른 계약 의존**: [[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]] D1 — SPA-direct 배치 선택 owner. [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] D1 — token custody owner. 본 branch는 두 foreign decision의 세부를 재진술하지 않는다.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서 근거가 있어도 내 프로젝트에서의 동작을 자동으로 보장하지 않는다.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 대상 Keycloak Capability config의 `PKCE method`를 비워두면 server-side PKCE 강제가 활성화되지 않아 `plain` 또는 PKCE 없는 요청도 허용되는지 | `KC-PKCE-C1`/`C3`은 UI 라벨과 S256 선택 시 동작을 설명하지만 미설정 default와 거부 응답을 직접 증명하지 않음. 버전별 설정 key 차이도 가능 | dev realm에서 `PKCE method`를 비운 경우와 `S256`인 경우를 각각 export해 JSON을 대조하고, verifier 없는 token 교환의 응답을 확인 | `needs-confirmation` |
|
||||
| Keycloak 의 authorization code TTL default = 60s | 본 branch 의 Sources 에 Keycloak code TTL default 명세 없음 (본문 메모만) | dev Keycloak realm settings > Tokens > Authorization Code Lifespan 캡처 | `needs-confirmation` |
|
||||
| Authorization code 1회용 정책 위반 시 (재사용) Keycloak 이 두 번째 요청 거부 + **이미 발급된 토큰 invalidate** | 본 branch 의 Sources 는 code 재사용 시 토큰 revoke 동작을 다루지 않음. RFC 6749 §4.1.2 는 본 branch Sources 표에 미링크 (메모만 언급) | dev 환경에서 같은 code 로 `/token` 2회 호출 후 첫 번째 token 으로 보호 API 호출 → 401 확인 | `needs-confirmation` |
|
||||
| `state` 파라미터 검증 누락 시 실제로 CSRF 공격으로 공격자 code 주입 가능 | OA21-C5 (redirect URI exact match) 는 다른 방어. state 검증 자체의 RFC 권고는 본 branch Sources 의 verbatim 인용 범위 밖 (OAuth 2.1 §4.1 등 별도 인용 필요) | RFC 6749 §10.12 또는 OAuth 2.1 §4.1.1 의 state 권고 verbatim 인용 추가 수집 | `needs-confirmation` |
|
||||
| OAuth 2.1 §7.5.1 의 PKCE 강제 예외 조건이 본 P2A 시나리오에 적용되지 않는다 (즉 PKCE 가 무조건 MUST) | OA21-C1 의 "Does not prove" 가 §7.5.1 예외의 정확한 조건 미명시를 인정 | RFC 9700 (OAuth 2.0 Security BCP) 또는 OAuth 2.1 §7.5.1 verbatim 발췌 후 P2A SPA public client 시나리오 매핑 | `needs-confirmation` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 이슈 1: `code_challenge_method`가 Keycloak 서버 측 client 설정에 강제되지 않으면 클라이언트가 `plain`을 보낼 위험.
|
||||
- 원인 가설: Keycloak client의 `PKCE method` 미설정 시 server-side enforcement가 비활성일 수 있음(실측 전 단정 금지)
|
||||
- 시도: (구현 없음, 문서 확인만)
|
||||
- 해결: client 설정에 `S256` 강제 — `documented-only`
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/keycloak-client-pkce-method-enforcement-official]]
|
||||
- [[raw/official-docs/oauth-v2-1-draft-ietf]]
|
||||
- [[raw/official-docs/oauth2-pkce-rfc-7636]]
|
||||
- [[raw/official-docs/oidc-client-ts-library]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
> 본 sub-sub-branch 는 leaf — 자식 자료 없음. Phase 3 P3A 실 구현 또는 외부 산출물 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 오류 기록 (이 sub-sub-branch 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 documented-only 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — Phase 3 실 구현 단계에 누적)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 머지/종료 시점에 채움. `/ingest`가 이 섹션을 기준으로 wiki/projects/에 추출.
|
||||
|
||||
- PR 링크: (미구현 — 문서까지만)
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: 없음 (P2A는 `documented-only` 범위)
|
||||
- **wiki 추출 대상**: 현 단계 없음. PKCE 자체는 `wiki/concepts/oauth2-pkce.md`로 합성 가능하나 6 패턴 비교 완성 이후 검토.
|
||||
- **추출하지 않을 항목**: P2A 구현 없음. `documented-only` 유지.
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
---
|
||||
title: branch / feature-keycloak-public-domain-tunneling (P3B 학습 환경 public 도메인 확보 — ngrok / Cloudflare Tunnel)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-CHILD-89A2896F
|
||||
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-public-domain-tunneling
|
||||
parent_branch: feature-keycloak-patterns
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, p3b, public-domain, ngrok, cloudflare-tunnel, https]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 5605aa356ecefd27cabba9dff6699e3058ee39235cbb0dd012b97176fb46a417
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-public-domain-tunneling (P3B 학습 환경 public 도메인 확보 — ngrok
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/branch-notes/feature-keycloak-patterns]] governance Work Item의 child. P3B 의미 계약은 [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]]를 참조한다. **Google OAuth redirect_uri 정책(HTTPS + localhost 외 IP 불가)** 때문에 학습 환경에서 어떻게 public URL을 확보할지 비교.
|
||||
> 본 sub-sub-branch는 **문서까지만** — 실 ngrok 구동 / Cloudflare Tunnel 설치 / EC2 도메인 매핑은 진행하지 않음. 등급 `documented-only` (P3B 전체 등급에 종속).
|
||||
> `status_label`: `in-progress`
|
||||
|
||||
<!-- 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 Google federation 변형의 public-domain tunnel 경계에 적용한다 | [[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 -->
|
||||
## 목표
|
||||
|
||||
P3A에서는 `localhost:8080`으로 모든 통신이 끝났지만 P3B는 **Google이 Keycloak callback URI로 redirect** 해야 한다. Google OAuth 2.0 client는 **redirect_uri를 HTTPS 도메인으로 제한** (localhost는 dev 한정 예외, raw IP 금지). 따라서 학습 환경에서도 public 접근 가능한 HTTPS URL을 어떻게 확보할지 결정해야 한다.
|
||||
|
||||
면접에서 답해야 할 질문:
|
||||
1. 학습 환경에서 왜 EC2 public IP만으론 부족한가? → Google이 IP 주소 redirect_uri 거부, HTTPS + 도메인 강제.
|
||||
2. 부모가 선택한 public URL 전략을 어떻게 실행하나? → [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]] D3가 provider 우선순위를 소유하고, 본 문서는 named tunnel·managed custom domain과 임시 random URL의 운영 차이만 구체화한다.
|
||||
3. 학습 → 운영 전환 시 무엇이 바뀌나? → tunnel 제거하고 EC2 public IP + Route53 A 레코드 + ACM cert로 대체.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- ngrok / Cloudflare Tunnel / EC2 + Route53 도메인 3개 옵션의 trade-off
|
||||
- 비교표: cost / static URL / TLS 자동 / 운영 비용 / inbound port 노출
|
||||
- Google redirect_uri 정책과 각 옵션의 적합도
|
||||
- 부모 [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]] D3의 채택 결정을 소비해 Cloudflare named tunnel·managed custom domain, ngrok 임시 URL, EC2 도메인 옵션의 실행 메커니즘을 정리
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 실 ngrok account 생성, Cloudflare account 연동, cloudflared 데몬 설치
|
||||
- 자체 도메인 구매 / Route53 hosted zone 생성
|
||||
- 운영용 ACM cert / ALB 구성 (P3B 운영 시나리오는 부모 sub-branch의 "대안 3"로만 언급)
|
||||
- ngrok / Cloudflare Tunnel의 enterprise 기능 (custom domain on free plan 제외, IP allowlist 등)
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
- [[raw/official-docs/ngrok-http-tunnel-official]] — ngrok HTTP tunnel 공식
|
||||
- [[raw/official-docs/cloudflare-tunnel-routing-official]] — Cloudflare Tunnel routing 공식
|
||||
- [[raw/official-docs/google-oauth2-redirect-uri-validation-official]] — Google redirect_uri 정책 (HTTPS + localhost 외 IP 불가)
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기.
|
||||
|
||||
- [ ] **ngrok 무료 plan 동작 확인** — `ngrok http 80` → `https://<random>.ngrok-free.app` 임시 URL 발급, 세션 종료 시 URL 변경 — 등급: `planned`
|
||||
- [ ] **Cloudflare Tunnel 동작 확인** — `cloudflared tunnel create <name>` + `cloudflared tunnel route dns <name> kc.example.com` → named tunnel과 Cloudflare가 관리하는 custom hostname 연결. `trycloudflare.com` quick tunnel의 random URL은 고정 callback으로 사용하지 않음 — 등급: `planned`
|
||||
- [ ] **EC2 public IP + Route53 도메인 옵션 정리** — Route53 hosted zone + A 레코드 + ACM cert + ALB (또는 EC2 직결 + nginx + Let's Encrypt) — 등급: `planned`
|
||||
- [ ] **비교표 작성** — cost / static URL / TLS 자동 / inbound port 노출 / 운영 비용 / Google Console redirect_uri exact match 적합도 — 등급: `planned`
|
||||
- [ ] **부모 결정 수용 확인** — [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]] D3의 Cloudflare named tunnel + managed custom domain 기본 경로와 random URL dev-only fallback을 본 실행 절차에 반영 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- ngrok free plan은 2026-05 기준 1 세션당 random subdomain. `https://<8자>.ngrok-free.app` 형식. 세션 끊기면 다음 세션은 다른 subdomain.
|
||||
- Cloudflare Tunnel의 `trycloudflare.com` quick tunnel은 무료지만 URL이 random (ngrok와 유사). 정적 도메인 원하면 Cloudflare account + 자체 도메인 (Cloudflare DNS로 위임) + named tunnel 필요.
|
||||
- EC2 public IP는 인스턴스 stop/start 시 변경 (Elastic IP 할당하면 고정). 도메인 매핑 안 하면 Google이 redirect_uri로 IP 거부.
|
||||
- 학습 환경 핵심: **Google Console에 등록한 redirect_uri와 실제 Keycloak issuer URL이 글자 단위로 일치**해야 함 (Google exact match 정책). URL 변경 시마다 Console 업데이트 필요.
|
||||
|
||||
### 비교표 초안
|
||||
|
||||
| 항목 | ngrok free | Cloudflare Tunnel (named) | EC2 + Route53 + ACM |
|
||||
|------|-----------|---------------------------|---------------------|
|
||||
| cost | 무료 | 무료 (Cloudflare account 필요) | Route53 hosted zone $0.50/월 + ACM 무료 + EC2 비용 |
|
||||
| static URL | ❌ (세션마다 변경) | ✅ (영구) | ✅ |
|
||||
| TLS 자동 | ✅ (ngrok edge) | ✅ (Cloudflare edge) | ACM + ALB (자동) 또는 Let's Encrypt (cron) |
|
||||
| inbound port 노출 | 불필요 (egress only) | 불필요 (egress only) | 필요 (443 open) |
|
||||
| 운영 비용 | 매 세션 Console 갱신 | 도메인 1회 설정 후 무 | DNS / cert / SG 관리 |
|
||||
| Google redirect_uri 적합도 | 낮음 (URL 변경 burden) | 높음 (정적) | 높음 (정적) |
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- **2026-05-25 (Historical / superseded selection wording)**: 본 문서가 Cloudflare 1순위·ngrok 2순위를 직접 결정한다고 적었으나, provider 우선순위의 owner는 부모 [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]] D3이다. 본 문서는 선택 결과의 운영 메커니즘만 소유한다.
|
||||
- **2026-07-18 (Reference-Only)**: 부모 D3가 Cloudflare 경로를 선택하면 **named tunnel + Cloudflare 관리 custom hostname**을 stable Google callback으로 사용한다. `trycloudflare.com` quick tunnel과 ngrok random hostname은 dev-only이며 URL이 바뀌면 Google Console 값을 함께 갱신한다.
|
||||
- **2026-05-25**: 운영 환경 옵션 **EC2 + Route53 + ACM + ALB**. 본 sub-sub-branch에서는 비교 대상으로만 기재, 실 구성은 P3B 전체가 `documented-only`이므로 진행 안 함.
|
||||
- **2026-05-25**: 본 sub-sub-branch 전체 등급 `documented-only`. P3B 부모 결정(문서까지만)에 종속. 실 tunnel 구동 / 도메인 매핑은 P3A 완료 후 선택적 확장 시점에 재검토.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. `Decision ID` 는 본 branch-note 안에서 안정적으로 유지.
|
||||
> `선택 조건` 열(R2): "이 조건일 때 이 결정, 다른 조건이면 어떤 대안". 분기 없으면 `N/A`.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | **Cloudflare 운영 profile(부모 D3 소비)** — stable Google callback이 필요하면 named tunnel을 Cloudflare가 관리하는 custom hostname(예: `kc.example.com`)에 연결한다. quick tunnel random URL은 이 profile에 포함하지 않는다. | 부모 [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]] D3가 Cloudflare를 선택하고, 관리 도메인을 확보할 수 있을 때. provider 선택 자체는 본 문서가 재정의하지 않는다. | `raw/official-docs/cloudflare-tunnel-routing-official.md#CLOUDFLARE-TUNNEL-C1` (cloudflared outbound), `raw/official-docs/cloudflare-tunnel-routing-official.md#CLOUDFLARE-TUNNEL-C2` (firewall inbound 차단 권장), `raw/official-docs/cloudflare-tunnel-routing-official.md#CLOUDFLARE-TUNNEL-C3` (tunnel `<UUID>.cfargotunnel.com` subdomain 자동 부여), `raw/official-docs/cloudflare-tunnel-routing-official.md#CLOUDFLARE-TUNNEL-C4` (사용자 hostname CNAME → cfargotunnel.com), `raw/official-docs/google-oauth2-redirect-uri-validation-official.md#GOOGLE-REDIR-C2` (Google redirect URI raw IP 금지 → 도메인 필요) | `official-vendor-doc + official-vendor-doc` | managed custom hostname의 Google 등록과 실제 callback 성공은 P3B 실측 필요. `<UUID>.cfargotunnel.com`이나 `trycloudflare.com` URL을 stable callback으로 간주하지 않는다. |
|
||||
| D2 | **ngrok 임시 운영 profile(부모 D3 fallback 소비)** — random URL은 dev-only이며 Google Console redirect_uri 갱신을 동반한다. | 부모 D3가 1회성 데모 fallback을 선택한 경우. 반복 사용·stable callback이면 부모 D3의 Cloudflare named tunnel profile로 돌아간다. | `raw/official-docs/ngrok-http-tunnel-official.md#NGROK-C1` (`ngrok http <port>` 가 random HTTPS hostname 생성), `raw/official-docs/ngrok-http-tunnel-official.md#NGROK-C3` (random hostname 은 기존 Domain object 와 매칭 안 됨), `raw/official-docs/ngrok-http-tunnel-official.md#NGROK-C4` (고정 URL에는 별도 Domain record + DNS CNAME 필요), `raw/official-docs/google-oauth2-redirect-uri-validation-official.md#GOOGLE-REDIR-C3` (exact match) | `official-vendor-doc + official-vendor-doc` | free plan에서 재시작마다 hostname이 바뀌는지는 `NGROK-C1` 인용에 직접 명시되지 않아 별도 확인 필요 |
|
||||
| D3 | 운영 환경 옵션 **EC2 + Route53 + ACM + ALB** — 비교 대상으로만 기재 | 운영(production) 환경이거나 tunnel 의존을 제거해야 할 때. 학습 환경이면 → **D1/D2**. **본 branch 범위 밖**(§범위 Out of scope: 운영 ACM/ALB 구성 제외 + 부모 note 의 "대안 3"과 동일 tree 관행: AWS 경로 = 명명된 비교 대안, 전용 raw 미첨부) — 비교 축으로만 존재 | UNSUPPORTED_DECISION / OUT_OF_BRANCH_SCOPE (AWS Route53 / ACM / ALB 공식 raw 미수집 — 본 branch 의 Sources 인용 범위 밖이자 운영 구성 결정은 별도 branch 영역) | `UNSUPPORTED_DECISION` | 실 채택 시점에 AWS 공식 raw 인용 보강 필요 (예: ACM cert 자동 갱신 정책) |
|
||||
| D4 | 본 sub-sub-branch 전체 등급 `documented-only` (P3B 부모 결정 종속, 실 tunnel 구동 보류) | P3A 완료 전 학습·문서 단계인 동안 적용. P3A 완료 후 선택적 확장 시점이면 → 실 tunnel 구동 / 도메인 매핑 재검토 (N/A — project scope 결정) | UNSUPPORTED_DECISION (project scope 결정 — 부모 branch P3B 의 `documented-only` 정책에 종속, 외부 raw 인용 불필요) | `UNSUPPORTED_DECISION` | scope 결정 자체는 외부 raw 가 root 가 아님 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 본 branch 는 `documented-only` — 실 코드/구동 없음. 따라서 본 §는 "다음 P3B 확장 작업자가 되묻지 않고 각 옵션을 셋업할 수 있는 수준"의 사전 명세 (등급은 전부 `planned`/`documented-only`). 각 sub-section 은 본 branch 의 `Decision ID` + `Supporting Claim ID` 에서 도출된 것만 기재한다.
|
||||
>
|
||||
> **범위 경계**: Keycloak `KC_HOSTNAME` / `KC_PROXY_HEADERS` / relative-path / TLS 종단 config 는 본 branch 결정 영역 밖(sibling `feature-keycloak-reverse-proxy-headers` / `feature-keycloak-https-termination-caddy-nginx` 소유) → 여기 재진술하지 않고 §엣지·실패·의존 에 의존 링크로만 둔다 (R3 OUT_OF_BRANCH_SCOPE).
|
||||
|
||||
### 1. Cloudflare Tunnel (D1) — 부모 D3 선택을 실행하는 named tunnel 셋업 절차
|
||||
|
||||
> **Trace**: D1 + `CLOUDFLARE-TUNNEL-C1`(outbound-only) / `C2`(inbound 차단 권장) / `C3`(`<UUID>.cfargotunnel.com` 자동 부여) / `C4`(사용자 hostname CNAME → cfargotunnel.com) / `C5`(`cloudflared tunnel route dns`, running 아니면 트래픽 없음).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (1) tunnel/도메인 명명(`kc.example.com`, `<name>`)은 예시값 — 사용자가 소유·위임한 Cloudflare 관리 도메인에 종속하며 raw 근거 없음(trade-off: 구체 도메인은 실 확장 시점 확정). (2) step 4 ingress `config.yml` 문법(`ingress:` 블록 / `service:` 매핑)은 `CLOUDFLARE-TUNNEL-C1`(outbound-only)이 증명하지 않는 미근거 detail(trade-off: ingress 규칙 공식 페이지 미수집 — 실 셋업 시 Cloudflare Tunnel `config.yml` 문서 참조). documented-only 이므로 둘 다 실 확장 시점 확정.
|
||||
|
||||
| 단계 | 명령 / 설정 | 근거 | 결과 |
|
||||
|---|---|---|---|
|
||||
| 1. 전제·인증 | Cloudflare account + Cloudflare DNS 로 위임한 도메인 1개 → `cloudflared tunnel login` (브라우저 인증 → `cert.pem`) | (계정 전제 — raw 밖) | named tunnel + 사용자 CNAME 가능 조건 |
|
||||
| 2. tunnel 생성 | `cloudflared tunnel create <name>` | `CLOUDFLARE-TUNNEL-C3` | tunnel UUID + `<UUID>.cfargotunnel.com` 자동 부여 |
|
||||
| 3. DNS 라우팅 | `cloudflared tunnel route dns <UUID-or-NAME> kc.example.com` | `CLOUDFLARE-TUNNEL-C4`, `C5` | 사용자 hostname → cfargotunnel.com CNAME 생성 (단 tunnel running 전엔 트래픽 없음) |
|
||||
| 4. ingress | `config.yml` 의 `ingress:` 블록에 `kc.example.com` → `service: http://localhost:8080`(Keycloak) 매핑 | `CLOUDFLARE-TUNNEL-C1`(outbound-only) + **UNSUPPORTED_IMPL_DECISION**(ingress 문법 미근거) | origin→Cloudflare outbound, ingress 규칙으로 Keycloak 라우팅 |
|
||||
| 5. 구동 | `cloudflared tunnel run <name>` | `CLOUDFLARE-TUNNEL-C1`, `C2` | EC2 SG inbound 0 개로 public HTTPS 노출, TLS 는 Cloudflare edge 종단 |
|
||||
|
||||
### 2. ngrok (D2) — quick tunnel 셋업 절차
|
||||
|
||||
> **Trace**: D2 + `NGROK-C1`(`ngrok http <port>` random HTTPS hostname) / `C3`(random hostname = Domain object 미매칭) / `C4`(bring-your-own-domain 절차) + `GOOGLE-REDIR-C3`(exact match → URL 변경 시 재등록).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: "재시작마다 hostname 변경" 은 `NGROK-C1` 인용 범위 밖(관행) → §검증해야 할 주장으로 이관해 별도 확인(trade-off: free plan 정책 페이지 미수집 상태에서 단정 금지).
|
||||
|
||||
| 단계 | 명령 / 설정 | 근거 | 결과 |
|
||||
|---|---|---|---|
|
||||
| 0. 전제 | 계정 가입 후 `ngrok config add-authtoken <token>` (authtoken 등록) | (계정 전제 — raw 밖) | ngrok agent 인증 완료 |
|
||||
| 1. 임시 URL | `ngrok http 8080` | `NGROK-C1`, `C2`(scheme https default) | `https://<random>.ngrok.app` 발급 |
|
||||
| 2. URL 변동성 | (재시작) | `NGROK-C3` | random hostname → reserved Domain 미매칭 → 세션마다 URL 변경 가능 → Google Console redirect_uri 재등록(`GOOGLE-REDIR-C3`) |
|
||||
| 3. 고정 URL(선택) | Domain record 생성 + DNS CNAME + matching hostname 으로 endpoint 생성 | `NGROK-C4` | 고정 URL 확보(단 free plan 가부는 미확인 — §검증) |
|
||||
|
||||
### 3. Google Cloud Console redirect URI 등록 제약 (D1·D2 공통)
|
||||
|
||||
> **Trace**: `GOOGLE-REDIR-C2`(host = raw IP 금지, localhost 예외) + `GOOGLE-REDIR-C3`(등록값과 byte-level exact match, 불일치 시 `redirect_uri_mismatch`).
|
||||
>
|
||||
> - **OUT_OF_BRANCH_SCOPE**: 등록할 broker endpoint URL 의 정확한 형식(`/realms/{realm}/broker/google/endpoint` + `KC_HTTP_RELATIVE_PATH` 결합)은 sibling `feature-keycloak-reverse-proxy-headers` / `feature-keycloak-google-redirect-uri-policy` 소유 → 링크만, 재진술 금지.
|
||||
|
||||
- 등록 host 는 **도메인 필수**(raw IP 금지, `GOOGLE-REDIR-C2`) → D1/D2 의 public URL 이 이 제약을 만족시키는 이유.
|
||||
- 등록값은 실제 요청 redirect_uri 와 **정확히 일치**(`GOOGLE-REDIR-C3`) → D2(ngrok random URL) 의 갱신 burden 이 여기서 발생.
|
||||
- stable callback은 `<UUID>.cfargotunnel.com` 또는 `trycloudflare.com` 주소가 아니라 Cloudflare가 관리하는 custom hostname을 사용한다. 그 hostname의 Google 등록 성공은 §검증해야 할 주장으로 남긴다.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> 정상 경로(public URL 확보 → Google redirect 통과) 외에 실 확장 시 부딪힐 실패/엣지와 다른 branch 계약 의존을 미리 열거.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **ngrok URL drift** — free plan 재시작 시 hostname 변경 가능 → 등록 redirect_uri 와 불일치 → `redirect_uri_mismatch`(`GOOGLE-REDIR-C3`). 기대 동작: dev-only로 제한하고 URL 변경 시 Console 갱신, stable callback은 부모 D3가 고른 D1 profile 사용.
|
||||
- **Cloudflare 라우팅 ≠ 가용** — `cloudflared` 미실행 시 CNAME 은 있어도 트래픽 안 흐름(`CLOUDFLARE-TUNNEL-C5`). 기대: `cloudflared tunnel run` 데몬 상시 실행(systemd 등).
|
||||
- **CNAME 전파 지연** — `cloudflared tunnel route dns` 직후 DNS 전파 지연(수초~수분)으로 등록 URL 이 일시 미해석 → Google redirect 일시 실패 가능. 기대: `dig <host>` 로 전파 확인 후 Google 등록/로그인 시도.
|
||||
- **cfargotunnel 도메인 정책 미검증** — Google 이 `<UUID>.cfargotunnel.com` generic subdomain 을 거부할 가능성(`GOOGLE-REDIR-C2` 는 raw IP 만 금지, generic subdomain 은 미보증). 기대: 거부 시 사용자 소유 도메인 CNAME 으로 우회(`CLOUDFLARE-TUNNEL-C4`). → §검증.
|
||||
- **outbound 443 차단 환경** — 방화벽이 outbound 를 막으면 cloudflared 미동작(`CLOUDFLARE-TUNNEL-C1` "Does not prove" 단서). 기대: egress 443 허용 확인.
|
||||
- **quick tunnel 혼동** — `trycloudflare.com` quick tunnel 은 random URL(ngrok 유사). 정적 도메인이 목표면 named tunnel + 계정 도메인 필요(진행 중 메모).
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-keycloak-reverse-proxy-headers]] `D7`(`KC_HOSTNAME=https://kc.example.com`) + `D1`(`KC_PROXY_HEADERS=xforwarded`) + `D6`(`KC_HTTP_ENABLED=true` + `KC_PROXY_TRUSTED_ADDRESSES`) — 본 branch 가 고른 public host 를 그 branch 가 Keycloak issuer 로 주입. 그 계약(hostname 형식 / `D3` relative path `/keycloak`)이 바뀌면 본 branch 의 redirect URI 등록값도 영향.
|
||||
- [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]] `D1`(broker endpoint URL 을 Authorized redirect URIs 에 등록) + `D8`(ngrok 운영 burden → Cloudflare 정적 도메인 채택 정당화) — 본 branch D2(ngrok URL 변경 burden)가 그 branch 의 갱신 운영(`D8`)과 결합.
|
||||
- [[raw/branch-notes/feature-keycloak-https-termination-caddy-nginx]] `D1`(Cloudflare edge TLS 종단) — 본 branch D1(Cloudflare Tunnel)과 짝: edge 종단이므로 EC2 내부는 HTTP forward. 본 branch D3(EC2 직결)로 가면 그 branch `D2`(Caddy) / `D3`(nginx) + Let's Encrypt 가 TLS 담당.
|
||||
- 부모 [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]] D3가 provider 우선순위와 채택 조건의 owner다. 본 sub-sub-branch D1/D2는 선택된 provider의 운영 profile만 제공하는 Reference-Only 문서다.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서나 사례는 근거지만, 내 프로젝트에서의 동작을 자동으로 보장하지 않는다.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| Cloudflare named tunnel에 연결한 managed custom hostname(예: `kc.example.com`)이 Google Cloud Console authorized redirect URI 등록과 실제 callback에 통과 | `GOOGLE-REDIR-C2`는 raw IP 금지만 보증하고, Cloudflare DNS·TLS·tunnel 조합의 종단 동작을 직접 보증하지 않음 | P3B에서 managed custom hostname을 등록하고 실제 broker login을 수행해 exact match와 callback 성공 확인 | `needs-confirmation` |
|
||||
| ngrok free plan 에서 재시작 시마다 hostname 이 변경 | `NGROK-C1` 은 random hostname 만 보증, 재시작 시 변경 정책은 본 raw 인용에 직접 없음 | ngrok pricing/free plan 페이지를 `raw/official-docs/` 로 등록 → free plan 의 reserved domain 정책 verbatim 확보 | `planned` |
|
||||
| Cloudflare Tunnel `trycloudflare.com` quick tunnel 이 무료 + URL random | 본 branch 의 진행 중 메모만 — `cloudflare-tunnel-routing-official` 인용에 직접 없음 | Cloudflare quick tunnel 공식 페이지 발췌 후 `raw/official-docs/` 등록 | `planned` |
|
||||
| Keycloak `KC_HOSTNAME=<tunnel-url>` 설정 시 issuer `iss` 가 정확히 `https://<tunnel-url>/realms/{realm}` 형식으로 발급 | Keycloak hostname 동작은 별도 raw (`keycloak-hostname-configuration`) 필요 | P3B 시연 시 token 발급 후 jwt.io 로 `iss` 디코딩 → backend `issuer-uri` 와 byte-level 비교 | `needs-confirmation` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 아직 없음 (문서 단계).
|
||||
|
||||
## 관련 sub-branch
|
||||
|
||||
- [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]] (부모, P3B Single EC2 + Google federation)
|
||||
- [[raw/branch-notes/feature-keycloak-reverse-proxy-headers]] — Keycloak reverse proxy 설정 (KC_PROXY_HEADERS + KC_HOSTNAME)
|
||||
- [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]] — Google OAuth client 등록 + ngrok URL 변경 시 갱신
|
||||
- [[raw/branch-notes/feature-keycloak-https-termination-caddy-nginx]] — HTTPS termination (Caddy vs nginx + Let's Encrypt)
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/google-oauth2-redirect-uri-validation-official]]
|
||||
- [[raw/official-docs/ngrok-http-tunnel-official]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
> 본 branch 는 leaf — 자식 자료 없음. errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 오류 기록
|
||||
|
||||
- (없음)
|
||||
|
||||
### 면접 준비
|
||||
|
||||
- (없음)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 본 sub-sub-branch는 **문서까지만**. 실 tunnel 구동 / 도메인 매핑 / Google Console 등록 흐름은 P3A 완료 후 선택적 확장.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: 해당 없음 (문서 단계, P3B 전체 `documented-only`)
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목: 없음
|
||||
- `locally-verified` 항목: 없음
|
||||
- `prod-verified` 항목: 없음
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
- 본 sub-sub-branch 전체가 `documented-only`. 추후 부모 P3B의 6 패턴 비교 매트릭스 내 "public 도메인 확보 비교표"로만 인용.
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
---
|
||||
title: branch / feature-keycloak-realm-client-export (Keycloak realm/client 설정 + realm JSON export)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-PATTERNS-OVERVIEW-002
|
||||
kind: project-work-item
|
||||
project: keycloak-patterns-overview
|
||||
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-002
|
||||
inherits: [DEC-KEYCLOAK-PATTERNS-OVERVIEW-REALM-CLIENT-001@1, DEC-KEYCLOAK-PATTERNS-OVERVIEW-SECRET-BOUNDARY-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-KEYCLOAK-PATTERNS-OVERVIEW-001]
|
||||
contract_packet: 1
|
||||
branch: feature-keycloak-realm-client-export
|
||||
parent_branch:
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, p3a, implementation, keycloak-realm, pkce, oidc-client]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: f751af30be9944511f5759f72096e406a9d6e191e075904df611b11f5797c5df
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-realm-client-export (Keycloak realm/client 설정 + JSON export)
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/project-notes/keycloak-patterns-overview]] Work Item의 직접 자식 branch.
|
||||
> **P3A는 실 구현 대상**. 본 sub-sub는 학습 + 작업 plan 기록 — 실 구현은 `/home/donghyeon/workspace/keycloak-patterns/`.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/project-notes/keycloak-patterns-overview]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: realm import와 client 4개 등록 후 unauthenticated protected endpoint가 401이고 export가 재현된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-REALM-CLIENT-001@1` | 단일 realm keycloak-patterns에서 인증 패턴별 client를 분리한다 | realm import와 인증 패턴별 client export 구성에 적용한다 | [[raw/project-notes/keycloak-patterns-overview]] |
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-SECRET-BOUNDARY-001@1` | confidential client secret은 env var로 주입하고 commit·realm export 평문을 금지한다 | realm export artifact의 secret redaction과 주입 경계에 적용한다 | [[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 -->
|
||||
## 목표
|
||||
|
||||
`keycloak-patterns` realm + `spa-client` (public, PKCE S256 강제) + 테스트 user 2명 + role 2개를 설정하고 realm JSON export를 commit한다. import 배선은 [[raw/branch-notes/feature-keycloak-docker-compose-stack]] D4가 소유하며, 본 문서는 그 consumer가 사용할 JSON artifact의 내용·검증 계약만 소유한다.
|
||||
|
||||
면접 질문: "Keycloak에서 public client에 PKCE 강제는 어떻게 거나요?"
|
||||
→ "대상 Keycloak Client의 Capability config에서 `PKCE method = S256`을 지정합니다. 실제 export의 client attribute와 verifier 없는 요청의 거부 응답은 배포 버전에서 확인합니다. RFC 7636 관점에서 client_secret을 안전하게 보관할 수 없는 public SPA의 code interception 위험을 PKCE로 완화합니다."
|
||||
|
||||
- 이슈:
|
||||
- PR: (별도 keycloak-patterns repo)
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- realm `keycloak-patterns` 생성
|
||||
- Client `spa-client` (public, Standard Flow + PKCE S256 강제)
|
||||
- Valid Redirect URIs (`http://localhost/*`, `http://127.0.0.1/*`)
|
||||
- Web Origins (`+` — Valid Redirect URI에서 자동 도출)
|
||||
- User 2명 (`admin-user` / `regular-user`) + 초기 password
|
||||
- Role 2개 (`admin-role` / `user-role`) + user에 매핑
|
||||
- Refresh Token Rotation 설정 필드와 target-version 실험 후보값 기록(현재 후보: ON / `Max Reuse: 0`; 의미는 owner 실험 전 확정하지 않음)
|
||||
- Realm JSON export 파일 commit (`./realm-export.json`)
|
||||
- import consumer가 사용할 realm JSON artifact의 파일명·내용·redaction 검증 계약
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- Google IdP 추가 (P3B → [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]])
|
||||
- 세밀한 role hierarchy / composite role
|
||||
- group / organization
|
||||
- 본격적인 password policy / OTP
|
||||
- `--import-realm`, volume mount, container command 등 import 배선 — [[raw/branch-notes/feature-keycloak-docker-compose-stack]] D4 소유
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 노트 최초 작성(2026-05-25) 당시 근거 raw 가 부재해 D1/D2/D5 가 `UNSUPPORTED_DECISION` 이었으나, 이후 corpus 성장으로 아래 raw 들이 추가되어 official 근거로 승격했다(재조사 없이 기존 raw 재매핑). 상세는 §Audit & Findings `EVIDENCE_UPGRADE`.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/oauth2-pkce-rfc-7636]] | D1 — public client 의 code interception 취약성 + S256 공식 + token endpoint 의 verifier 불일치 거부 (`PKCE-RFC7636-C1/C3/C4`) |
|
||||
| [[raw/official-docs/keycloak-client-pkce-method-enforcement-official]] | D1 — Keycloak client 단위 PKCE 강제 옵션의 정식 명칭("PKCE method")과 S256 값 동작 (`KC-PKCE-C1/C3`) |
|
||||
| [[raw/official-docs/oauth2-browser-based-apps-ietf-draft]] | D1 — 브라우저 앱 = public client(client credentials 없음) 표준 정의 (`OAUTH-BBA-C3`) |
|
||||
| [[raw/official-docs/oauth-v2-1-draft-ietf]] | D1 — 모든 client PKCE MUST + AS enforce MUST (`OA21-C1`); D2 — redirect URI exact-match MUST (`OA21-C5`); D3 — refresh token scope/RS bound + code flow 발급 경로 (`OA21-C3/C6`) |
|
||||
| [[raw/official-docs/keycloak-getting-started-docker]] | D2 — quickstart 자체가 redirect URI 를 `.../*` wildcard + Web origins 정확값으로 설정 (`KC-GSD-C4`); realm=tenant, client 등록 절차 (`KC-GSD-C3`) |
|
||||
| [[raw/official-docs/keycloak-import-export-realms]] | D5 — `--import-realm` startup import + 컨테이너 import dir(`/opt/keycloak/data/import`) + 기존 realm skip(멱등) + offline `--override` 차이 (`KC-IMPORT-C1..C4`) |
|
||||
| [[raw/official-docs/keycloak-server-containers-docker]] | D5 — 컨테이너 실행/env context; 정확한 env 이름·기본 포트는 `KC-CONTAINER-C5` 가 `needs-confirmation` |
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] Keycloak admin console 접속 (`http://localhost:8080`, admin 계정) — 등급: `planned`
|
||||
- [ ] realm `keycloak-patterns` 생성 — 등급: `planned`
|
||||
- [ ] Client `spa-client` 생성: Access Type `public`, Standard Flow Enabled, Direct Access Grants Disabled — 등급: `planned`
|
||||
- [ ] Client Capability config: `PKCE method = S256`; 생성된 realm export의 내부 key도 함께 확인 — 등급: `planned`
|
||||
- [ ] Client Valid Redirect URIs: `http://localhost/*`, `http://127.0.0.1/*` 양쪽 등록 — 등급: `planned`
|
||||
- [ ] Client Web Origins: `+` (Redirect URIs에서 자동 도출) — 등급: `planned`
|
||||
- [ ] Role 생성: realm role `admin-role`, `user-role` — 등급: `planned`
|
||||
- [ ] User 생성: `admin-user` (password 초기화, `admin-role` 부여) — 등급: `planned`
|
||||
- [ ] User 생성: `regular-user` (password 초기화, `user-role` 부여) — 등급: `planned`
|
||||
- [ ] Realm Settings → Tokens: owner 실험 profile에 따라 `Revoke Refresh Token`과 `Refresh Token Max Reuse` 값을 설정하고 export에 기록(0/1의 의미는 사전 단정 금지) — 등급: `planned`
|
||||
- [ ] Realm Settings → Tokens: Access Token Lifespan 5분 (학습용 짧게) — 등급: `planned`
|
||||
- [ ] Realm export: admin console → Export 또는 `kc.sh export --realm keycloak-patterns --file /opt/keycloak/data/import/realm-export.json` — 등급: `planned`
|
||||
- [ ] export JSON에서 secret/password 제거(또는 placeholder 치환) 후 git commit — 등급: `planned`
|
||||
- [ ] [[raw/branch-notes/feature-keycloak-docker-compose-stack]] D4가 본 artifact를 소비하는지 acceptance 검증(경로·파일명·realm/client 존재 확인); mount/command 저작은 하지 않음 — 등급: `planned`
|
||||
- [ ] 환경 reset 후 consumer import acceptance 검증 (`docker compose down -v && up` → admin 로그인 → realm/client 존재 확인) — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- **PKCE는 Keycloak public client 기본 권장.** S256만 허용(`plain` 거부)이 보안 표준.
|
||||
- `Valid Redirect URIs`에 `localhost`와 `127.0.0.1` 둘 다 등록하는 이유: 브라우저가 어느 호스트로 SPA를 로드하느냐에 따라 redirect URI도 달라짐. 두 URI는 Keycloak이 다른 것으로 본다.
|
||||
- Web Origins `+`는 Redirect URIs 도메인을 자동으로 CORS allow에 추가. wildcard `*`는 학습에서도 비권장.
|
||||
- Realm export의 credential 포함 여부·표현 형식은 Keycloak 버전과 export mode에 따라 달라질 수 있으며 현재 근거로 확정할 수 없다. target image의 `kc.sh export --help`, 실제 JSON, re-import 후 로그인까지 확인하기 전에는 password 포함/미포함을 모두 가정하지 않는다. 기본 절차는 credential을 별도 bootstrap/reset하고 commit 전 민감 필드를 redact하는 것이다.
|
||||
- `--import-realm`은 Keycloak 19+ 부터 지원 (자동 import). 구버전은 `kc.sh import` 별도 실행.
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- 2026-05-25: **public client + PKCE S256 강제.** 이유: vanilla JS SPA는 client_secret 보관 불가 (RFC 7636), public client + PKCE가 표준.
|
||||
- 2026-05-25: **Redirect URI에 wildcard `/*` 사용.** 이유: 학습 환경 한정 (localhost callback 경로 자유로움). prod에서는 정확한 경로 하나만.
|
||||
- 2026-05-25 (Historical / superseded rationale): **Refresh Token Rotation ON + Max Reuse 0**을 곧바로 보안 정책으로 확정했으나, `Max Reuse` 의미와 reuse 후 family 동작은 target-version 실험 전 단정할 수 없다. 현 결정은 [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] D1/D2의 정책·검증 계약을 소비해 확정된 값을 export에 기록하는 것이다.
|
||||
- 2026-05-25: **Access Token Lifespan 5분.** 이유: rotation/revoke 시연 시 access token이 즉시 invalidate 안 됨을 짧게 검증.
|
||||
- 2026-05-25: **Realm JSON export commit.** 이유: 환경 reset 1줄 정책 (`docker compose down -v && up` 후 실 import).
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. `Decision ID` 는 본 branch-note 안에서 안정적으로 유지.
|
||||
> `선택 조건` 열: "이 조건일 때 이 결정, 다른 조건이면 어떤 대안". 분기 없으면 `N/A`.
|
||||
> D3/D4/D5 는 sibling owner 브랜치에 rationale/wiring 을 **위임(delegate)** 한다 — Single-Owner(consistency-contract) 준수, 재진술(RESTATED_FOREIGN_DECISION) 금지. 상세는 §Audit & Findings.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | public client + PKCE `S256` 강제 (vanilla JS SPA 는 client_secret 보관 불가) | SPA 가 client_secret 을 안전 보관 못할 때 이 결정 (`OAUTH-BBA-C3`, `PKCE-RFC7636-C1`). backend 를 둘 수 있으면 → 대안 BFF confidential client (`OA21-C4`) | `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/oauth2-pkce-rfc-7636.md#PKCE-RFC7636-C4`, `raw/official-docs/keycloak-client-pkce-method-enforcement-official.md#KC-PKCE-C1`, `raw/official-docs/keycloak-client-pkce-method-enforcement-official.md#KC-PKCE-C3`, `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C1` | `official-standard + official-vendor-doc` | `code_challenge_method=plain`/verifier 없는 요청의 **4xx 거부**는 `KC-PKCE-C3` 가 증명 안 함(강제 적용 암시만) → §Claims To Verify 로 실측. Admin UI 라벨은 "PKCE method"(§Audit `NAMING_DRIFT`) |
|
||||
| D2 | Redirect URI 에 wildcard `/*` 사용 (localhost 학습 한정) | localhost/학습이면 `/*` (callback 경로 자유·quickstart 도 `/*` 사용 `KC-GSD-C4`). prod 진입 시 → 대안 exact-match 단일 경로 (표준 `OA21-C5`: AS MUST reject non-exact redirect URI) | `raw/official-docs/keycloak-getting-started-docker.md#KC-GSD-C4`, `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C5` | `official-vendor-doc + official-standard` | prod 에서 `/*` 는 `OA21-C5` exact-match MUST 위반 — out of scope(Out of scope 는 아니나 prod 미대상). `KC-GSD-C4` 는 `/*` 예시만 보증, wildcard 의 보안 영향은 미증명 |
|
||||
| D3 | realm export에 rotation 실험 후보값을 기록하되 의미를 재정의하지 않음 | 값·정책의 owner인 [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] D1/D2가 확정한 target-version profile을 소비한다. runtime 동작은 [[raw/branch-notes/feature-keycloak-refresh-rotation-and-logout]] D1/D5에서 관찰한다. | `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C3`, `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C6` + delegated | `official-standard (baseline) + delegated` | `Max Reuse=0`의 의미와 refresh-token family invalidation은 배포 버전 실험 전 확정하지 않는다. 본 branch는 export에 최종 선택값만 반영한다. |
|
||||
| D4 | Access Token Lifespan 실험값을 realm export에 기록 | TTL 정책은 [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] D2를 소비하고, 5분이라는 실행 편의값과 관찰은 [[raw/branch-notes/feature-keycloak-refresh-rotation-and-logout]] D2에서 검증한다. prod TTL은 별도 결정이다. | `UNSUPPORTED_DECISION` + delegated | `UNSUPPORTED_DECISION + delegated` | target-version export의 내부 key와 실제 만료 시간이 일치하는지 runtime acceptance 필요 |
|
||||
| D5 | realm-export.json **저작 + commit** (realm/client/role/user/token 설정을 담고 민감 필드를 검토·redact) | 환경 reset 반복 + realm 즉시 복원이 목표면 export artifact를 commit. 1회성 수동 설정이면 Admin UI 수동 생성. 기존 realm 강제 덮어쓰기는 offline `import --override`(`KC-IMPORT-C4`) 검토 | `raw/official-docs/keycloak-import-export-realms.md#KC-IMPORT-C2`, `raw/official-docs/keycloak-import-export-realms.md#KC-IMPORT-C3`, `raw/official-docs/keycloak-import-export-realms.md#KC-IMPORT-C4` + import 배선 delegate [[raw/branch-notes/feature-keycloak-docker-compose-stack]] D4 | `official-vendor-doc + delegated (wiring)` | secret/password/credential 포함 여부와 형식은 모두 `needs-confirmation`. import wiring은 compose sibling D4 owner이며 본 문서는 artifact만 소유한다. |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 본 branch 는 실 구현 repo(`/home/donghyeon/workspace/keycloak-patterns/`)가 **아직 부재**(§Audit `NO_GROUND_TRUTH`) → 모든 항목 `planned`. code grep 으로 `actually-implemented` 확정 불가.
|
||||
> 3-rule(CLAUDE.md §15.5): R1 Reference 필수 · R2 UNSUPPORTED_IMPL_DECISION 명시 · R3 OUT_OF_BRANCH_SCOPE 정제.
|
||||
|
||||
### 1. realm-export.json 저작 명세 (client
|
||||
|
||||
> **Trace**: D1(`PKCE-RFC7636-C1/C3/C4`, `KC-PKCE-C1/C3`, `OA21-C1`) · D2(`KC-GSD-C4`, `OA21-C5`) · D3([[raw/branch-notes/feature-keycloak-refresh-token-rotation]] D1/D2 + runtime [[raw/branch-notes/feature-keycloak-refresh-rotation-and-logout]] D1/D5) · D4([[raw/branch-notes/feature-keycloak-refresh-token-rotation]] D2 + runtime [[raw/branch-notes/feature-keycloak-refresh-rotation-and-logout]] D2)
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**:
|
||||
> - client 내부 JSON 속성 key `pkce.code.challenge.method` — `KC-PKCE-C1` "Does not prove" 가 이 내부 속성명이 page 에 명시 안 됨을 명시. Admin UI 라벨("PKCE method")만 증명됨. trade-off: **export-first** 접근(Admin UI 에서 설정 → `kc.sh export` 로 정확한 key 자동 생성) 채택, hand-author JSON key 는 금지. `needs-confirmation`.
|
||||
> - realm token 설정 JSON key(`revokeRefreshToken`/`refreshTokenMaxReuse`/`accessTokenLifespan`) — cited raw 에 verbatim 부재. trade-off: 마찬가지로 export-first 로 확정. `needs-confirmation`.
|
||||
> - user credential의 export 포함 여부와 JSON 표현(`users[].credentials[]` 등) — cited raw 미증명. target `kc.sh export --help`, 실제 export JSON, re-import login을 확인하기 전에는 포함/미포함을 가정하지 않는다. trade-off: 별도 bootstrap/reset을 default로 두고 hand-authored credential block은 피한다. `needs-confirmation`.
|
||||
|
||||
| JSON path (planned, export-first 로 확정) | 값 | 근거 | 상태 |
|
||||
|---|---|---|---|
|
||||
| `realm` | `keycloak-patterns` | D5 / 범위 | `planned` |
|
||||
| `clients[].clientId` | `spa-client` | 범위 | `planned` |
|
||||
| `clients[].publicClient` | `true` | D1 `PKCE-RFC7636-C1` (SPA=public), `OAUTH-BBA-C3` | `planned` |
|
||||
| `clients[].standardFlowEnabled` | `true` | 범위 (Standard Flow = Authorization Code) | `planned` |
|
||||
| `clients[].directAccessGrantsEnabled` | `false` | 범위 (ROPC 비활성) | `planned` |
|
||||
| `clients[].attributes."pkce.code.challenge.method"` | `S256` | D1 `KC-PKCE-C3`(UI "PKCE method"=S256). **내부 key = UNSUPPORTED_IMPL** | `needs-confirmation` |
|
||||
| `clients[].redirectUris` | `["http://localhost/*","http://127.0.0.1/*"]` | D2 `KC-GSD-C4` (redirect URI 형식) | `planned` |
|
||||
| `clients[].webOrigins` | `["+"]` | 범위 (Redirect URI 에서 CORS 자동 도출) | `planned` |
|
||||
| `roles.realm[].name` | `admin-role`, `user-role` | 범위 | `planned` |
|
||||
| `users[].username` (+ `realmRoles`) | `admin-user`(admin-role), `regular-user`(user-role) | 범위 | `planned` |
|
||||
| `users[].credentials[]` (초기 password) | 포함 여부·형식 미확정. 별도 bootstrap/reset을 default로 두고 hand-author 금지 | 범위("초기 password") + §엣지 + Claims To Verify #4 | `needs-confirmation` |
|
||||
| realm token: `revokeRefreshToken` | owner가 확정한 target-version 실험값(현재 candidate `true`) | D3 → [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] D1/D2. **key = UNSUPPORTED_IMPL** | `needs-confirmation` |
|
||||
| realm token: `refreshTokenMaxReuse` | owner가 0/1 실험 후 확정한 값(현재 candidate `0`) | D3 → concept owner + runtime observation. **key = UNSUPPORTED_IMPL** | `needs-confirmation` |
|
||||
| realm token: `accessTokenLifespan` | `300` (5분, 실행 편의 candidate) | D4 → concept owner + runtime [[raw/branch-notes/feature-keycloak-refresh-rotation-and-logout]] D2. **key = UNSUPPORTED_IMPL** | `needs-confirmation` |
|
||||
|
||||
### 2. export 절차 + secret/password redaction
|
||||
|
||||
> **Trace**: D5(`KC-IMPORT-C4` offline export 경로, `KC-CONTAINER` context)
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `kc.sh export` 의 정확한 flag(`--dir` vs `--file`, `--users` 옵션)와 client secret 이 export 에 평문 포함되는지 = cited raw 미증명 → §Claims To Verify. trade-off: 실 export 1회 수행 후 JSON 을 grep 으로 확인하는 절차로 대체.
|
||||
|
||||
1. Admin console 에서 realm/client/role/user/token 설정 (§1 표대로).
|
||||
2. export: `kc.sh export --realm keycloak-patterns --file /opt/keycloak/data/import/realm-export.json` (또는 `--dir`). `--users` 처리 정책은 §엣지 참조.
|
||||
3. commit 전 redact: `grep -n 'secret\|password\|credential' realm-export.json` → 평문 노출 필드는 placeholder 치환 또는 `.gitignore`.
|
||||
4. `./realm-export.json` 로 repo 에 commit.
|
||||
|
||||
### 3. import 배선 (OUT_OF_BRANCH_SCOPE — delegate)
|
||||
|
||||
> **Trace / R3**: volume mount + Keycloak 부트 command `--import-realm`는 sibling [[raw/branch-notes/feature-keycloak-docker-compose-stack]] D4가 owner (`KC-IMPORT-C1/C2/C3/C4` 인용). 본 branch는 **realm-export.json 저작과 consumer acceptance만** 담당하며 배선을 재진술하지 않는다. 배선 계약이 바뀌면 본 export 파일 배치에 영향(§엣지·의존).
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4 캡처: 정상 경로 외 실패/엣지 + 다른 계약 의존(대상 브랜치 + Decision ID).
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **export JSON 에 client secret 평문 포함 가능** — public client 는 secret 없지만 confidential 전환 시 위험. 기대 동작: commit 전 `secret` grep + redact(§구현 가이드 2-3). (§Claims To Verify #3)
|
||||
- **credential 포함 여부 미확정** — export mode·version에 따라 password/credential 포함 여부와 형식이 다를 수 있다. 기대 동작: 별도 bootstrap/reset을 기본으로 하고 commit 전 `secret|password|credential` 검색, 실제 re-import login으로 검증. (§Claims To Verify #4)
|
||||
- **기존 realm 존재 시 auto-import skip** — `KC-IMPORT-C3`(멱등). `docker compose down -v` 로 postgres volume 을 삭제해야 재import 됨(볼륨 잔존 시 옛 realm 유지, 새 export 반영 안 됨).
|
||||
- **import dir 파일명/확장자 오류** — `KC-IMPORT-C2`: `.json` regular file 만 읽고 sub-dir 무시. 경로/확장자 오타 시 silent skip → 부트는 성공하나 realm 없음.
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-keycloak-docker-compose-stack]] D4 — `--import-realm` + volume mount **배선 owner**. 그 D4의 import dir 경로/flag가 바뀌면 본 export 파일 배치 위치·이름에 영향.
|
||||
- [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] D1/D2 — rotation 값·정책 owner. [[raw/branch-notes/feature-keycloak-refresh-rotation-and-logout]] D1/D5 — target-version 실행·관찰 owner. 결과가 바뀌면 본 realm-export.json token 섹션 동기화 필요.
|
||||
- [[raw/branch-notes/feature-keycloak-single-ec2-no-google]] D3 — `KC_HOSTNAME=localhost` + issuer-uri + network 계약 owner. 이 hostname 계약이 D2의 redirect URI 값(`http://localhost/*`, `http://127.0.0.1/*`)의 전제이며, parent D3가 hostname/port를 바꾸면 함께 동기화한다. 본 client scope는 parent D5의 실 구현에 소비된다.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서나 사례는 근거지만, 내 프로젝트에서의 동작을 자동으로 보장하지 않는다.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| 대상 Keycloak Client Capability config의 `PKCE method = S256` 설정이 token endpoint에서 `code_verifier` 없는 요청을 거부하는지 | `KC-PKCE-C1/C3`은 UI 라벨과 S256 선택을 다루지만 exact HTTP status와 version별 내부 key는 증명하지 않음 | target UI 설정 후 realm export의 내부 key를 확인하고 PKCE 없는 token request의 응답을 기록 | `needs-confirmation` |
|
||||
| `--import-realm` 옵션의 정확한 명령 형식 (`docker run ... start-dev --import-realm` 또는 `kc.sh start --import-realm`) | `KC-CONTAINER-C5` 가 명시적으로 `needs-confirmation` — env/option verbatim 부재 | Keycloak all-config / import 공식 페이지 발췌 후 `raw/official-docs/` 에 추가하여 verbatim 인용 확보 | `needs-confirmation` |
|
||||
| Realm export JSON 에 client secret 이 평문으로 포함될 수 있음 → gitignore / redact 필요 | 본 branch 진행 중 메모 — 1차 raw 미수집 | 실 export 수행 후 JSON 파싱 → `secret` 필드 검색 + 평문 노출 여부 확인 | `needs-confirmation` |
|
||||
| target Keycloak의 export mode가 user credential을 어떤 조건·형식으로 포함하는지 | `usersExport=true`와 password 포함을 연결하는 1차 근거가 없고 버전별 CLI 옵션 차이 가능 | target image에서 `kc.sh export --help` 확인 → 실제 JSON의 credential 필드 검사 → re-import 후 로그인 검증 | `needs-confirmation` |
|
||||
| 환경 reset (`docker compose down -v && up`) 후 realm/client/user 자동 import 동작 | 위 D5 가 `needs-confirmation` — 실제 동작 검증 미수행 | docker-compose 구동 → admin 로그인 → realm `keycloak-patterns` 존재 + `spa-client` 존재 확인 | `planned` |
|
||||
| realm-export.json 의 client PKCE 내부 속성 key 가 `pkce.code.challenge.method` 인지 | `KC-PKCE-C1` "Does not prove" — page 에 내부 속성명 미명시 | Admin UI 에서 "PKCE method=S256" 설정 후 `kc.sh export` → 생성된 JSON 의 `clients[].attributes` key 확인 | `needs-confirmation` |
|
||||
|
||||
## Audit & Findings
|
||||
|
||||
> `/branch-spec` 채움 중 발견한 정합/근거 이슈. 사용자 작성 결정 영역은 자동 rewrite 하지 않고 정합 권고만 기록(CLAUDE.md §11, consistency-contract).
|
||||
|
||||
- **`EVIDENCE_UPGRADE`** — 노트 최초 작성(2026-05-25) 당시 D1/D2/D5 는 근거 raw 부재로 전부 `UNSUPPORTED_DECISION` 이었다. 이후 corpus 성장으로 `oauth2-pkce-rfc-7636`, `keycloak-client-pkce-method-enforcement-official`, `oauth2-browser-based-apps-ietf-draft`, `oauth-v2-1-draft-ietf`, `keycloak-getting-started-docker`, `keycloak-import-export-realms` 가 추가되어 official 근거로 승격. **재조사(researcher dispatch) 없이 기존 raw 재매핑으로 해결** — 모든 결정이 근거 보유 또는 정당한 UNSUPPORTED trade-off(D4).
|
||||
- **`NAMING_DRIFT` (해소 2026-07-18)** — §목표·§TODO·§Claims를 대상 UI의 **`PKCE method`**(Capability config)로 통일했다. 다른 Keycloak 버전의 라벨·내부 key 차이는 생성된 realm export와 대조하며, exact 거부 응답은 `needs-confirmation`으로 유지한다.
|
||||
- **`OWNERSHIP_NARROWED` (D5)** — 원 D5는 "export commit + `--import-realm` 자동 import"를 함께 기술했으나, `--import-realm` + volume mount 배선은 sibling [[raw/branch-notes/feature-keycloak-docker-compose-stack]] D4가 owner다. 본 D5는 realm-export.json 저작·commit과 consumer acceptance로 좁혔다.
|
||||
- **`DELEGATED_RATIONALE` (D3/D4)** — rotation 값·정책은 [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] D1/D2, 실행·관찰은 [[raw/branch-notes/feature-keycloak-refresh-rotation-and-logout]] D1/D5가 owner다. 본 branch는 target-version 결과로 확정된 값을 realm-export.json에 기록할 뿐 `Max Reuse`나 family invalidation 의미를 재진술하지 않는다.
|
||||
- **`NO_GROUND_TRUTH` (code)** — 실 구현 repo `/home/donghyeon/workspace/keycloak-patterns/` **부재** 확인. 코드 grep 으로 `actually-implemented` 확정 불가 → 모든 항목 `planned`/`documented-only` 유지. (ca-tmpl ground truth 는 본 keycloak-patterns 프로젝트에 비적용 — 별개 트리)
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- (구현 시작 후 추가) realm export JSON 안에 client secret이 평문으로 들어가는 경우 — gitignore 또는 redact 필요.
|
||||
- (구현 시작 후 추가) user password 재설정 자동화 어려움 — 초기 password 정책 / temporary password flag 활용 검토.
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/keycloak-getting-started-docker]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
> 본 sub-sub-branch 는 leaf — 자식 자료 없음. Phase 3 P3A 실 구현 또는 외부 산출물 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 오류 기록 (이 sub-sub-branch 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 documented-only 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — Phase 3 실 구현 단계에 누적)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 실 구현 후 realm import 자동화 검증 시 `planned` → `actually-implemented`/`locally-verified` 승급.
|
||||
|
||||
- PR 링크: (별도 keycloak-patterns repo)
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: 로컬 docker-compose / 단일 EC2 시뮬레이션
|
||||
- **wiki 추출 대상**:
|
||||
- `actually-implemented` 항목: (구현 후 채움)
|
||||
- `locally-verified` 항목: (구현 후 채움)
|
||||
- `prod-verified` 항목: (없음)
|
||||
- **추출하지 않을 항목**: 현재 전부 `planned`.
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
---
|
||||
title: branch / feature-keycloak-refresh-rotation-and-logout (refresh token rotation + 로그아웃 흐름 + JWT stateless 한계)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-PATTERNS-OVERVIEW-007
|
||||
kind: project-work-item
|
||||
project: keycloak-patterns-overview
|
||||
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-007
|
||||
inherits: [DEC-KEYCLOAK-PATTERNS-OVERVIEW-SPA-CLIENT-001@1, DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-KEYCLOAK-PATTERNS-OVERVIEW-004]
|
||||
contract_packet: 1
|
||||
branch: feature-keycloak-refresh-rotation-and-logout
|
||||
parent_branch:
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, p3a, implementation, refresh-token, rotation, logout, jwt-revocation]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 64fd889a4fded12eee171744601a4a80a43037d485ca72fbf8491e80bc3067bd
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-refresh-rotation-and-logout (refresh token rotation + 로그아웃 흐름)
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/project-notes/keycloak-patterns-overview]] Work Item의 직접 자식 branch.
|
||||
> **P3A는 실 구현 대상**. 본 sub-sub는 학습 + 작업 plan 기록 — 실 구현은 `/home/donghyeon/workspace/keycloak-patterns/`.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/project-notes/keycloak-patterns-overview]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: refresh rotation과 logout 후 session·token 무효화가 검증된다
|
||||
|
||||
<!-- 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를 사용한다 | public SPA의 refresh rotation과 logout 흐름에 적용한다 | [[raw/project-notes/keycloak-patterns-overview]] |
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1` | done-bar는 E2E success와 signature security failure 재현·해결 evidence다 | rotation·logout 후 session과 token 무효화 검증 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 -->
|
||||
## 목표
|
||||
|
||||
[[raw/branch-notes/feature-keycloak-refresh-token-rotation]] D1/D4가 정의한 rotation 정책·검증 계약을 target Keycloak 버전에서 실행한다. `Max Reuse=0/1`의 실제 의미와 RT 재사용 후 영향 범위를 관찰하고, logout/revoke 흐름과 **JWT의 stateless 한계**도 시연한다.
|
||||
|
||||
면접 질문: "JWT를 즉시 무효화할 수 있나요?"
|
||||
→ "self-contained access token을 로컬 검증하면 revoke 결과가 즉시 반영되지 않을 수 있어 짧은 TTL을 사용합니다. refresh token rotation은 사용된 RT를 무효화하고 새 RT를 발급하지만, 예전 RT 재사용 시 후속 RT나 session까지 어떻게 영향받는지는 Keycloak 버전별 실험으로 확인해야 합니다. 초 단위 무효화가 필요하면 introspection 같은 stateful 검증의 비용을 별도로 평가합니다."
|
||||
|
||||
- 이슈:
|
||||
- PR: (별도 keycloak-patterns repo)
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- concept owner의 candidate profile을 소비해 `Revoke Refresh Token = ON`과 `Refresh Token Max Reuse = 0/1`을 각각 설정·비교
|
||||
- SPA가 silent renew 호출 시마다 새 refresh token 받는 것 확인 (DevTools)
|
||||
- 동일 refresh token 2회 사용 시도 후 RT_1 응답, RT_2 후속 사용, realm session 상태를 분리 관찰
|
||||
- `/protocol/openid-connect/revoke` 엔드포인트 호출 (refresh token revoke)
|
||||
- `/protocol/openid-connect/logout?id_token_hint=...&post_logout_redirect_uri=...` 흐름
|
||||
- 함정 시연: access token revoke 즉시 적용 안 됨 (다음 만료까지 유효)
|
||||
- 짧은 access token 만료(5분)의 트레이드오프 측정
|
||||
- backend가 `iat`/`exp` 확인하는 방식 (Spring 기본 동작)
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- backend가 매 요청 introspection 호출 (stateless 포기 패턴)
|
||||
- Keycloak event listener / custom SPI
|
||||
- distributed token blacklist 캐시 (Redis 등)
|
||||
- back-channel logout receiver 구현과 provider-trigger E2E — 현재 문서에서는 옵션 시연도 하지 않으며 endpoint를 가정하지 않음. 필요 시 전용 branch 신설
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 이 branch의 구현·설계 결정의 **근거가 되는 외부 자료**. 같은 자료가 여러 결정의 근거면 결정 표시와 함께 여러 번 등장 가능. Claim ID 는 각 raw 의 `## Claims Extracted` 표에서 안정적으로 유지.
|
||||
|
||||
| Source | 정당화하는 결정 (Claim) |
|
||||
|---|---|
|
||||
| [[raw/official-docs/oauth2-token-revocation-rfc-7009]] | **D4** revoke 계약(`RFC7009-C1`~`C3`: endpoint·`token`·`token_type_hint` 파라미터), **D4** refresh revoke 시 관련 access token SHOULD 무효화(`RFC7009-C4`), **D3** stateless JWT trap 의 표준 원인(`RFC7009-C5`: self-contained AT → RS 추가 상호작용 불필요), **D2** 짧은 TTL 이 RFC 자신이 제시하는 설계 대안(`RFC7009-C6`, 방향성만·수치 미권고) |
|
||||
| [[raw/official-docs/keycloak-oidc-logout-endpoint-official]] | **D4** logout 흐름 — RP-Initiated Logout endpoint(`KC-LOGOUT-C1/C2`), `id_token_hint` 미전달 시 confirm(`C3`), `post_logout_redirect_uri` auto redirect + 필수 동반 파라미터(`C4/C5`), `Valid Post Logout Redirect URIs` 매칭 검증(`C6`), Backchannel Logout URL(`C7`) |
|
||||
| [[raw/official-docs/keycloak-refresh-token-rotation-reuse-admin-official]] | **D1/D5** "Revoke Refresh Token" 토글 = rotation(사용된 RT 무효화 + 새 토큰 발급) 공식 정의(`KC-ROT-C1`), **D2** "Access Token Lifespan" 설정(`C3`) + 짧은 lifespan = 유출 완화 공식 원칙(`C5`). ⚠️ "Refresh Token Max Reuse" 설정명 + "재사용 시 family invalidate" 자동 동작은 이 공식 문서(v26.7.0)에 **부재**함을 전수 검색으로 확인(`KC-ROT-C6`, negative finding) → D1/D5 의 그 부분은 `UNSUPPORTED_DECISION` 유지 |
|
||||
| [[raw/official-docs/security-jwt-rfc-7519-validation]] | **D3** `exp` 시각 도달 이후 JWT MUST NOT be accepted(`JWT-RFC7519-C2`) — "revoke 직후엔 200, 만료 후 401" 함정의 표준 근거 |
|
||||
| [[raw/official-docs/spring-security-resource-server-jwt]] | **D3** backend(Spring RS)가 `issuer-uri` 로 self-config + JWKS 서명/`iss`/`exp` 만 검증하고 매 요청 introspection 안 함(`SSRS-JWT-C1/C2`) — self-contained 검증 구성 확인 |
|
||||
| [[raw/official-docs/oidc-client-ts-library]] | **D5** SPA silent renew 메커니즘 — Refresh Token Grant(`OIDCTS-C4`) + Silent Refresh in iframe(`OIDCTS-C5`). `signoutRedirect()` 로 `/logout` redirect(라이브러리 API, `needs-confirmation`) |
|
||||
| [[raw/official-docs/oauth-v2-1-draft-ietf]] | **D1/D5** rotation 권고 배경 — refresh token MUST be bound to scope/resource server(`OA21-C3`), code grant 가 AT+RT 발급 표준 경로(`OA21-C6`) |
|
||||
| [[raw/official-docs/keycloak-securing-apps-overview-official]] | (일반 배경) Keycloak 통합 시 표준 protocol 우선 / adapter 는 last resort(`KC-SECAPP-C1/C2`). rotation/revoke/logout 구체 동작의 verbatim 은 이 overview 에 없음 — 위 전용 raw 들이 대체 |
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [ ] [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] D1 profile을 소비해 `Revoke Refresh Token = ON`에서 `Refresh Token Max Reuse = 0`과 `1`을 각각 설정; UI/export key도 기록 — 등급: `planned`
|
||||
- [ ] Keycloak admin: Access Token Lifespan 5분 (짧게) — 등급: `planned`
|
||||
- [ ] SPA login → access_token / refresh_token 1 발급 — 등급: `planned`
|
||||
- [ ] silent renew 호출 1회 → 새 access_token + 새 refresh_token 2 수신 확인 → refresh_token 1 invalidate — 등급: `planned`
|
||||
- [ ] **reuse 관찰 시연**: refresh_token 1을 다시 사용한 응답 status/body를 기록하고, refresh_token 2의 후속 사용과 realm session 상태를 별도로 확인. 0/1 profile 결과를 비교하며 family invalidation을 expected result로 두지 않음 — 등급: `planned`
|
||||
- [ ] `/protocol/openid-connect/revoke` 엔드포인트로 refresh_token 명시적 revoke (curl) — 등급: `planned`
|
||||
- [ ] **함정 시연**: revoke 직후 동일 access_token으로 `/api/me` 호출 → 200 OK (만료 전이므로 유효) → 5분 뒤 호출 → 401 — 등급: `planned`
|
||||
- [ ] DevTools Network 탭에서 `/token` (`grant_type=refresh_token`) 호출 시 응답 body 캡처 (rotation 전후 refresh_token 값 비교) — 등급: `planned`
|
||||
- [ ] SPA logout button → `userManager.signoutRedirect()` → Keycloak `/logout?id_token_hint=...&post_logout_redirect_uri=http://localhost/` 호출 확인 — 등급: `planned`
|
||||
- [ ] logout 후 Keycloak session 종료 → SPA `/api/me` 호출 시 access token 만료 전이면 여전히 200 → 함정 재확인 — 등급: `planned`
|
||||
- [ ] logout 후 brower에서 Keycloak 다시 접근 시 SSO session 없어 재로그인 필요 확인 — 등급: `planned`
|
||||
- [ ] backend에서 `exp` claim 만료 시 401 응답 코드 확인 (Spring 기본 동작 검증) — 등급: `planned`
|
||||
- [ ] 트레이드오프 정리 노트: "stateless JWT vs 즉시 무효화" — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- **Refresh Token Rotation의 확인된 동작**: 사용된 refresh token을 무효화하고 새 refresh token을 발급한다. 예전 RT의 재등장은 탈취뿐 아니라 client race/retry일 수도 있으므로 원인을 단정하지 않는다.
|
||||
- **Max Reuse 의미는 실험 대상**: 0과 1에서 같은 sequence를 실행하고 응답·후속 RT·session 상태를 비교한다. race가 결과를 섞지 않도록 시연 중 단일 refresh thread를 보장한다.
|
||||
- **access token revoke 즉시 적용 안 되는 이유**: backend가 매 요청마다 Keycloak에 introspection 안 함 — JWT signature/iss/aud/exp만 검증. 그게 JWT의 본질적 트레이드오프.
|
||||
- **짧은 access token 만료**: 5분으로 줄이면 revoke 후 최대 5분 노출 — 학습 단계 권장. prod는 1–15분 권장 (보안 vs Keycloak 부하 트레이드오프).
|
||||
- **`id_token_hint`의 역할**: logout 시 어느 session을 끝낼지 식별. ID Token이 없으면 Keycloak이 logout 페이지에서 "정말 로그아웃?" 추가 확인 UI 표시.
|
||||
- **`post_logout_redirect_uri`**: Keycloak client 설정에 `Valid post logout redirect URIs`로 사전 등록 필요 (현재 Keycloak 18+).
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- 2026-07-18: 본 branch의 D1은 보안 정책 결정이 아니라 **실행 test profile**이다. [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] D1을 소비해 rotation ON에서 Max Reuse 0/1을 모두 관찰하며 값 의미를 미리 정하지 않는다.
|
||||
- 2026-05-25: **Access Token Lifespan 5분.** 이유: revoke 함정을 짧은 대기 시간으로 검증 가능.
|
||||
- 2026-05-25: **introspection 패턴은 out of scope.** 이유: JWT stateless를 포기하는 트레이드오프 — 학습 목적은 stateless의 한계를 인지하는 것.
|
||||
- 2026-05-25: **명시적 revoke + logout 분리 학습.** 이유: 두 흐름이 다른 endpoint를 사용하는 것을 직접 확인.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 2026-07-18 `/branch-spec` 자동조사 반영: RFC 7009(revoke) + Keycloak logout endpoint + Keycloak "Revoke Refresh Token" 설정 정의 + RFC 7519 `exp` + Spring RS + oidc-client-ts + OAuth 2.1 raw 를 Sources 에 연결. 결과 — **D3·D4 는 official 근거로 완전 해소**, D1·D2·D5 는 **부분 해소**(방향/메커니즘 확보, 잔여 `UNSUPPORTED`).
|
||||
> 잔여 `UNSUPPORTED` 는 근거 부족이 아니라 *공식 문서에 없음을 전수 검색으로 확정한 gap*(Keycloak "Refresh Token Max Reuse" 설정명 + family-invalidate 자동 동작 = `KC-ROT-C6` negative finding) 또는 *어느 표준도 권고 안 하는 임의 수치*("5분")다. 둘 다 admin UI 실측/실험으로만 닫힌다 → `## Claims To Verify` 참조.
|
||||
> `Decision ID` 는 이 branch-note 안에서 안정적으로 유지. `Supporting Claims` 는 `raw/<category>/<slug>.md#Cn` 형식.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | **실행 test profile** — concept owner [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] D1을 소비해 rotation ON + Max Reuse 0/1을 동일 조건에서 비교 | target-version 의미를 검증할 때 두 profile 모두 실행. 한 값을 보안상 우월하다고 사전 분류하지 않음 | `raw/official-docs/keycloak-refresh-token-rotation-reuse-admin-official.md#KC-ROT-C1` + `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C3` + `UNSUPPORTED_DECISION`(`KC-ROT-C6`) | `official-vendor-doc + official-standard + needs-confirmation (Max Reuse semantics)` | UI/export에서 필드 존재를 확인하고 실험 결과에 따라 concept owner를 갱신. 본 D1이 독립 정책 owner가 되지 않음 |
|
||||
| D2 | Access Token Lifespan 5분 — revoke 함정 짧은 대기로 검증 | 학습 단계엔 5분(revoke 함정을 5분 대기로 관찰 가능). prod 는 보안 vs Keycloak 부하 균형으로 1~15분 구간에서 선택 | `raw/official-docs/keycloak-refresh-token-rotation-reuse-admin-official.md#KC-ROT-C3` (Access Token Lifespan 설정 존재/역할) + `#KC-ROT-C5` (짧은 lifespan = 유출 완화 Keycloak 공식 원칙) + `raw/official-docs/oauth2-token-revocation-rfc-7009.md#RFC7009-C6` (short-lived AT = RFC 설계 대안) + `raw/official-docs/security-jwt-rfc-7519-validation.md#JWT-RFC7519-C2` (`exp` 후 MUST NOT accept — 만료 대기가 검증 방법인 이유). **`UNSUPPORTED_IMPL_DECISION`**: "5분" 정확한 수치는 어느 표준도 분 단위 미권고 (trade-off: 짧을수록 revoke 노출창↓ but refresh 왕복↑·서버 부하↑; 5분은 학습 대기시간 편의로 임의 선택) | `official-vendor-doc (KC-ROT-C3/C5 방향) + official-standard (RFC7009-C6, JWT-RFC7519-C2) + UNSUPPORTED_IMPL_DECISION (5분 수치)` | OWASP/Keycloak 공식 권장 TTL 구간 raw 추가 시 수치 보강 |
|
||||
| D3 | introspection 패턴은 out of scope — JWT stateless 트레이드오프 학습 목적 | stateless 한계 *인지*가 목표면 introspection out of scope. 진짜 즉시 무효화가 요구되면 대안: 매 요청 introspection 또는 opaque/reference token 채택(별도 branch, stateless 이점 포기) | `raw/official-docs/oauth2-token-revocation-rfc-7009.md#RFC7009-C5` (self-contained AT → RS 추가 상호작용 불필요 = revoke 즉시 반영 안 됨의 표준 원인) + `raw/official-docs/security-jwt-rfc-7519-validation.md#JWT-RFC7519-C2` (`exp` 후 MUST NOT accept) + `raw/official-docs/spring-security-resource-server-jwt.md#SSRS-JWT-C1` (Spring RS = signature/`iss`/JWKS 검증, 매 요청 introspection 안 함) | `official-standard (RFC7009-C5, JWT-RFC7519-C2) + official-vendor-doc (SSRS-JWT-C1/C2)` | Keycloak 이 기본 self-contained JWT access token 을 발급하는지 실측(token decode) — RFC/Spring 은 일반 아키텍처만 증명 |
|
||||
| D4 | 명시적 revoke + logout 분리 학습 — 두 endpoint 의 다른 동작 직접 확인 | 특정 토큰만 즉시 폐기(SSO session 유지 가능)면 `/revoke`. 사용자 로그아웃(브라우저 SSO session 종료)까지면 `/logout`. 목적이 달라 분리 시연 | (revoke) `raw/official-docs/oauth2-token-revocation-rfc-7009.md#RFC7009-C1`~`C4` (endpoint·`token`·`token_type_hint`·refresh revoke SHOULD cascade AT) + (logout) `raw/official-docs/keycloak-oidc-logout-endpoint-official.md#KC-LOGOUT-C1`~`C6` (logout endpoint·RP-initiated redirect·`id_token_hint`·`post_logout_redirect_uri`·Valid Post Logout Redirect URIs 매칭) | `official-standard (RFC 7009: RFC7009-C1~C4) + official-vendor-doc (Keycloak logout: KC-LOGOUT-C1~C6)` | Keycloak 세션이 `/logout` 호출 후 실제로 종료되는지 실측(runtime) — 명세는 확보, 동작은 미검증 (Claims To Verify #3) |
|
||||
| D5 | **실행 관찰 절차** — concept owner [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] D4를 소비해 RT_1 사용→AT_2+RT_2 발급→RT_1 재사용 응답→RT_2 후속 사용→realm session 상태를 순서대로 기록 | D1의 0/1 profile 각각에 동일 절차 적용. family invalidation은 가능한 관찰 결과 중 하나일 뿐 expected result가 아님 | `raw/official-docs/keycloak-refresh-token-rotation-reuse-admin-official.md#KC-ROT-C1`, `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C3`, `#OA21-C6`, `raw/official-docs/oidc-client-ts-library.md#OIDCTS-C4`, `#OIDCTS-C5` + `UNSUPPORTED_DECISION`(`KC-ROT-C6`) | `official-vendor-doc + official-standard + needs-confirmation (reuse impact)` | status/body, RT_2 유효성, session 상태를 독립 증거로 남기고 concept owner D4에 결과 반영 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> *결정*이 "*무엇*"이라면 본 §는 "*어디에 어떻게*"의 사전 명세. 본 branch 는 코드베이스가 아니라 **Keycloak admin 설정 + curl/DevTools 실험** 이 "구현"이므로, sub-section 은 설정 카탈로그·endpoint 계약·실험 시퀀스로 구성한다. ⚠️ 실 구현 repo `/home/donghyeon/workspace/keycloak-patterns/` 는 현재 **미생성**(controller 확인) → 아래 모든 항목은 `planned`, 코드 존재 주장 없음.
|
||||
|
||||
### 1. Keycloak Realm Token/Session 설정 카탈로그 (D1·D2)
|
||||
|
||||
> **Trace**: D1(concept owner의 0/1 test profile) + D2(Access Token Lifespan 5분). 근거: `KC-ROT-C1`(Revoke Refresh Token 정의)·`KC-ROT-C3`(Access Token Lifespan 정의).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**:
|
||||
> - `Refresh Token Max Reuse` 필드명과 0/1 의미 — `KC-ROT-C6`(공식 문서 부재). admin UI/export 및 runtime 비교 전까지 실험 변수로만 취급한다.
|
||||
> - `Access Token Lifespan = 5분` 수치 — 표준 미권고(D2 trade-off). 학습 대기시간 편의로 임의 선택.
|
||||
|
||||
| 위치 (admin console) | 설정 | 값 | 근거 |
|
||||
|---|---|---|---|
|
||||
| Realm Settings → Sessions/Tokens | Revoke Refresh Token | `ON` | `KC-ROT-C1` (Enabled → 사용된 RT revoke + 새 토큰 발급) |
|
||||
| Realm Settings → Sessions/Tokens | Refresh Token Max Reuse | `0`, `1` 두 profile | `UNSUPPORTED_IMPL_DECISION` (`KC-ROT-C6` 부재 — UI/export + runtime 비교) |
|
||||
| Realm Settings → Sessions/Tokens | Access Token Lifespan | `5m` | `KC-ROT-C3` (설정 역할) + `UNSUPPORTED_IMPL_DECISION` (수치) |
|
||||
| Client(`spa-client`) → Logout settings | Valid Post Logout Redirect URIs | `http://localhost/` 등록 | `KC-LOGOUT-C6` (매칭 필수) — §3 참조 |
|
||||
|
||||
### 2. Rotation reuse-detection 시연 시퀀스 (D5)
|
||||
|
||||
> **Trace**: D5(rotation flow). 근거: `KC-ROT-C1`(rotation 동작) + `OIDCTS-C4/C5`(SPA silent renew) + `OA21-C3/C6`(배경).
|
||||
>
|
||||
> - **관찰 경계**: RT_1 재사용 후 family 전체 invalidation은 문서로 증명되지 않았다(`KC-ROT-C6`). 따라서 시퀀스는 expected result가 아니라 응답·RT_2·session을 분리 측정하는 절차다.
|
||||
|
||||
```text
|
||||
1) SPA login (oidc-client-ts UserManager.signinRedirect) → AT_1 + RT_1 발급 [OIDCTS-C3/C4]
|
||||
2) silent renew 1회 (startSilentRenew) → /token grant_type=refresh_token(RT_1)
|
||||
→ Keycloak: RT_1 revoke + AT_2 + RT_2 반환 [KC-ROT-C1]
|
||||
3) stolen token 시뮬레이션: curl 로 RT_1 재사용 → /token(RT_1)
|
||||
→ 관찰 A: HTTP status/body [UNSUPPORTED — KC-ROT-C6]
|
||||
4) RT_2 로 다음 refresh 수행 → 관찰 B: 성공/실패와 응답
|
||||
5) realm session 상태 확인 → 관찰 C: session 유지/종료
|
||||
```
|
||||
|
||||
- DevTools Network 탭: 2)의 `/token` 응답 body 에서 `refresh_token` 값이 RT_1→RT_2 로 바뀌는지 캡처(rotation 증거).
|
||||
- ⚠️ silent renew와 manual curl이 동시에 RT_1을 쓰면 어떤 호출이 먼저 소비했는지 불명확해진다. manual 시연 시 silent renew를 일시 중단하고 순서를 로그 timestamp로 고정한다.
|
||||
|
||||
### 3. 명시적 revoke + RP-Initiated Logout endpoint 계약 (D4)
|
||||
|
||||
> **Trace**: D4(revoke/logout 분리). 근거: revoke = `RFC7009-C1~C4`, logout = `KC-LOGOUT-C1~C6`. UNSUPPORTED 없음(양 endpoint 모두 official 근거 확보).
|
||||
|
||||
| 흐름 | 요청 | 파라미터 계약 | 근거 |
|
||||
|---|---|---|---|
|
||||
| refresh token revoke | `POST /realms/<realm>/protocol/openid-connect/revoke` | `token=<RT>` (REQUIRED) · `token_type_hint=refresh_token` (OPTIONAL) · `client_id=<spa-client>` | `RFC7009-C1/C2/C3` |
|
||||
| revoke 부수효과 | (위 동일) | RT revoke 시 동일 grant 의 access token 도 **SHOULD** 무효화(AS 지원 시) — MUST 아님 | `RFC7009-C4` |
|
||||
| RP-Initiated Logout | `GET /realms/<realm>/protocol/openid-connect/logout?id_token_hint=<id_token>&post_logout_redirect_uri=http://localhost/` | `id_token_hint` 없으면 confirm UI(`C3`) · `post_logout_redirect_uri` 쓰려면 `client_id` 또는 `id_token_hint` 동반(`C5`) · 값은 Valid Post Logout Redirect URIs 와 매칭(`C6`) | `KC-LOGOUT-C1~C6` |
|
||||
| SPA 트리거 | `UserManager.signoutRedirect()` | 라이브러리가 위 logout URL 구성 | `OIDCTS` (API `needs-confirmation`) |
|
||||
|
||||
### 4. Stateless JWT trap 검증 절차 (D3)
|
||||
|
||||
> **Trace**: D3(stateless 한계 학습). 근거: `RFC7009-C5`(self-contained AT) + `JWT-RFC7519-C2`(`exp` 후 MUST NOT accept) + `SSRS-JWT-C1/C2`(Spring RS 검증 구성). UNSUPPORTED 없음.
|
||||
|
||||
```text
|
||||
1) /protocol/openid-connect/revoke 로 RT revoke (또는 logout)
|
||||
2) 동일 AT 로 backend GET /api/me 즉시 호출 → 기대: 200 OK
|
||||
(AT 만료 전 · Spring RS 는 서명/iss/exp 만 검증, revoke 사실 모름) [RFC7009-C5, SSRS-JWT-C1]
|
||||
3) Access Token Lifespan(5분) 경과 후 재호출 → 기대: 401
|
||||
(exp 도달 → MUST NOT be accepted) [JWT-RFC7519-C2]
|
||||
```
|
||||
|
||||
- backend 설정: `spring.security.oauth2.resourceserver.jwt.issuer-uri` 한 줄(= `KC_HOSTNAME` 기반 issuer 와 정확 일치, `SSRS-JWT-C1`). issuer 불일치 시 401 — §엣지·의존 참조([[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] 의존).
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4 캡처용. 정상 경로 외에 *구현(시연) 중 부딪힐* 실패/엣지 + 다른 계약 의존.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **SPA race로 관찰 오염**: silent renew와 manual `/token`이 동시에 RT_1을 제출하면 누가 토큰을 먼저 소비했는지 알 수 없다. 시연 시 silent renew를 끄거나 in-flight refresh를 단일 promise로 직렬화한다.
|
||||
- **`post_logout_redirect_uri` 미등록**: client `Valid Post Logout Redirect URIs` 에 없으면 매칭 실패(`KC-LOGOUT-C6`) → logout 거부/에러. 기대: 사전 등록(§구현 가이드 1, [[raw/branch-notes/feature-keycloak-realm-client-export]] 의존).
|
||||
- **`id_token_hint` 누락**: `post_logout_redirect_uri` 만 주고 `id_token_hint`/`client_id` 둘 다 없으면 confirm UI 노출(`KC-LOGOUT-C3/C5`) → 자동 redirect 안 됨. 기대: `id_token_hint` 동반.
|
||||
- **revoke 후 만료 전 AT = 여전히 200** (함정 그 자체): `RFC7009-C5` 로 표준상 예상되는 결과. "버그"가 아니라 stateless 아키텍처의 정상 동작 — 학습 포인트.
|
||||
- **Keycloak `iss`/`issuer-uri` 불일치**: `KC_HOSTNAME` 과 backend `issuer-uri` 가 다르면 JWT `iss` 검증 실패로 revoke/logout 시연 이전에 401(`SSRS-JWT-C1`).
|
||||
- **다른 계약 의존** (sibling branch + 그 Decision ID — 소비하는 계약이 어느 결정에서 확정됐는지):
|
||||
- [[raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce]] `#D1`·`#D3` — SPA 토큰 발급(`#D1` oidc-client-ts 채택) + silent renew(`#D3` `automaticSilentRenew: true`) 구현. 본 branch D5 시연이 이 SPA 흐름을 consume. 그 계약(로그인/갱신 방식)이 바뀌면 rotation 시연 절차 영향.
|
||||
- [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] `#D1`·`#D6` — `KC_HOSTNAME=localhost`(`#D1`) + backend `iss`/`issuer-uri` 문자열 일치 + JWKS 도달 메커니즘(`#D6`, ⚠️ 그 branch 기준 현재 기본=(C)). 본 branch D3 의 backend 401 검증(`SSRS-JWT-C1`) 전제.
|
||||
- [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] `#D3` — backend = stateless + JWT 만 사용(`#D3`)의 Resource Server 검증 경로. 본 branch D3 의 `exp`→401 이 이 검증 체인 위에서 동작.
|
||||
- [[raw/branch-notes/feature-keycloak-docker-compose-stack]] `#D1`·`#D4` — Keycloak 26.x 실행(`#D1`) + realm JSON auto-import(`#D4`). 모든 실측 TODO 의 실행 환경 전제.
|
||||
- [[raw/branch-notes/feature-keycloak-realm-client-export]] D5 — realm-export.json 저작·commit에 client `Valid Post Logout Redirect URIs` 등록 포함. 본 branch D4 logout의 전제. rotation 값·정책은 concept [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] D1/D2가 owner이고 본 branch D1/D5는 실행만 담당한다.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 구현 전/중/후에 실제 검증해야 하는 주장. P3A는 실 구현 대상이며, D1/D5의 0/1 비교 결과를 concept owner에 환류한다.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| `Max Reuse=0`과 `1`에서 RT_1 재사용이 RT_2와 realm session에 미치는 영향 | 필드·값 의미와 family invalidation 동작의 verbatim 인용 없음(`KC-ROT-C6`); 버전별 차이 가능 | 두 profile에서 동일하게 RT_1 사용→RT_2 발급→RT_1 재사용→RT_2 사용→session 확인. 각 status/body를 독립 기록 | `planned` |
|
||||
| `/protocol/openid-connect/revoke` 로 RT revoke 후 동일 access_token 으로 `/api/me` 호출 시 만료 전에는 200 응답 (stateless JWT 한계) | JWT stateless backend 동작의 표준 근거는 확보(`RFC7009-C5`)나 Keycloak+Spring 실동작 미검증 | Access Token Lifespan 5분 설정 → revoke 직후 호출 (200 예상) → 5분 후 호출 (401 예상) | `planned` |
|
||||
| Keycloak `/protocol/openid-connect/logout?id_token_hint=...&post_logout_redirect_uri=...` 흐름이 OIDC RP-Initiated Logout 1.0 을 준수 | spec 근거는 확보(`KC-LOGOUT-C1~C6`)나 Keycloak 세션이 실제 종료되는지 runtime 미검증 | Keycloak admin UI 의 logout endpoint 동작 캡처 + logout 후 SSO session 없어 재로그인 필요 확인 | `needs-confirmation` |
|
||||
| `post_logout_redirect_uri` 가 Keycloak client `Valid post logout redirect URIs` 에 사전 등록 필요 | 근거 확보(`KC-LOGOUT-C6`); 내 client 설정에서 실제 매칭·거부 미확인 | Keycloak admin UI 에서 client 설정 캡처 + 미등록 URI 로 logout 시 거부 확인 | `needs-confirmation` |
|
||||
| `oidc-client-ts` silent renew와 manual `/token` 호출이 겹칠 때 실험 순서가 오염되는지 | silent renew 존재는 확보했지만 동시 호출 순서와 target Keycloak 결과는 미검증 | timestamp와 Keycloak log로 두 요청 순서를 기록하고, 정식 0/1 비교 실험은 silent renew OFF로 재실행 | `planned` |
|
||||
| Spring Security Resource Server 가 `exp` claim 만료 시 401 응답 (basic JWT 검증 동작) | 표준(`JWT-RFC7519-C2`)+Spring 구성(`SSRS-JWT-C1/C2`) 근거 확보나 실제 401 응답 미확인 | 5분 후 호출 시 401 응답 캡처 | `planned` |
|
||||
|
||||
## 관심사 커버리지
|
||||
|
||||
> **EXEMPT** — `rules/coverage-gate.md` §7: `governing_docs` 미지정 + `related_projects` 에 ca-skeleton/ca-tmpl 없음(= `[keycloak-patterns]` 학습 노트) → coverage 게이트 면제. 완전성 기준(governing canonical 문서)이 존재하지 않으므로 관심사 매트릭스를 생성하지 않는다. 깊이(depth R1~R4)만 게이트 대상.
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- (구현 시작 후 추가) `post_logout_redirect_uri`가 client에 등록 안 되어 logout 실패 예상 — sub-5-2에서 추가 등록 필요.
|
||||
- (구현 시작 후 추가) `oidc-client-ts` silent renew와 manual refresh token 호출이 충돌 가능 — manual 시연 시 silent renew 일시 OFF.
|
||||
- (구현 시작 후 추가) refresh token 요청 race가 0/1 비교 결과를 오염할 가능성 — DevTools와 Keycloak log에서 호출 순서를 확인.
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/keycloak-refresh-token-rotation-reuse-admin-official]]
|
||||
- [[raw/official-docs/oauth2-token-revocation-rfc-7009]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
- [[raw/branch-notes/feature-keycloak-refresh-token-rotation]]
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
> 본 sub-sub-branch 는 leaf — 자식 자료 없음. Phase 3 P3A 실 구현 또는 외부 산출물 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 오류 기록 (이 sub-sub-branch 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 documented-only 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — Phase 3 실 구현 단계에 누적)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> rotation 재사용 시연, revoke 함정 시연, logout 전체 흐름이 로그/캡처로 증명되면 `planned` → `actually-implemented`/`locally-verified` 승급. 트레이드오프 노트는 향후 `wiki/concepts/`로 ingest 후보.
|
||||
|
||||
- PR 링크: (별도 keycloak-patterns repo)
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: 로컬 docker-compose / 단일 EC2 시뮬레이션
|
||||
- **wiki 추출 대상**:
|
||||
- `actually-implemented` 항목: (구현 후 채움)
|
||||
- `locally-verified` 항목: (구현 후 채움)
|
||||
- `prod-verified` 항목: (없음)
|
||||
- **추출하지 않을 항목**: 현재 전부 `planned`.
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
---
|
||||
title: branch / feature-keycloak-refresh-token-rotation (Refresh token rotation + revocation)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-CHILD-579E54CC
|
||||
kind: branch-child
|
||||
project: keycloak-patterns-overview
|
||||
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-007
|
||||
inherits: [DEC-KEYCLOAK-PATTERNS-OVERVIEW-SPA-CLIENT-001@1, DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-KEYCLOAK-PATTERNS-OVERVIEW-004]
|
||||
contract_packet: 1
|
||||
branch: feature-keycloak-refresh-token-rotation
|
||||
parent_branch: feature-keycloak-refresh-rotation-and-logout
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, p2a, refresh-token, rotation, revocation, keycloak]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 41b3c869ae7eecc249938a19e9501c8b8cecdb11d589882f2babb6d10f63618a
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-refresh-token-rotation — Refresh token rotation + revocation
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/branch-notes/feature-keycloak-refresh-rotation-and-logout]] Work Item branch의 child.
|
||||
> **목적**: refresh token rotation의 개념·정책·검증 계약 owner로서, 공식 확인된 `Revoke Refresh Token` 동작과 target-version 실험이 필요한 `Refresh Token Max Reuse`/reuse 결과를 구분한다. `/revoke` 계약과 JWT stateless 한계도 함께 정리한다.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/branch-notes/feature-keycloak-refresh-rotation-and-logout]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: refresh rotation과 logout 후 session·token 무효화가 검증된다
|
||||
|
||||
<!-- 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를 사용한다 | public SPA의 refresh token rotation·revocation 계약에 적용한다 | [[raw/project-notes/keycloak-patterns-overview]] |
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1` | done-bar는 E2E success와 signature security failure 재현·해결 evidence다 | parent의 rotation·logout 검증을 위한 개념·실험 계약에 적용한다 | [[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 -->
|
||||
## 목표
|
||||
|
||||
P2A는 SPA가 refresh_token을 보유하므로 탈취 시 공격자가 access_token을 계속 갱신할 수 있다. 공식 자료로 확인된 rotation 계약은 **사용된 refresh token을 무효화하고 새 refresh token을 발급한다**는 범위까지다(`KC-RTROT-C1`/`C2`). 이미 사용한 토큰을 다시 제출했을 때 후속 토큰까지 무효화되는지, 그 범위가 token family 전체인지, `Max Reuse` 값별 의미가 무엇인지는 target Keycloak 버전의 실행 실험으로만 확정한다.
|
||||
|
||||
핵심 질문:
|
||||
|
||||
- Keycloak에서 rotation을 켜는 정확한 설정 항목과 위치는?
|
||||
- 이미 사용한 refresh token을 다시 제출하면 어떤 토큰·세션이 무효화되는가? (`Max Reuse=0`과 `1` 비교 관찰)
|
||||
- `/protocol/openid-connect/revoke` endpoint 사용 방법?
|
||||
- logout 시 access_token / refresh_token / session을 어떻게 정리?
|
||||
- 함정: **JWT access_token은 stateless** — revoke를 호출해도 만료까지 검증을 통과한다. 즉시성 확보 방법은?
|
||||
|
||||
본 sub-sub-branch는 **Keycloak Realm Settings 경로 + rotation flow + revocation endpoint + logout 정리 + stateless 한계**를 정리.
|
||||
|
||||
- 이슈: (학습 노트, 이슈 없음)
|
||||
- PR: (구현 없음)
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
> 본 sub-sub-branch 는 **P2A 개념·계약 정리(`documented-only`)**. "무엇이 어떻게 동작하는가 + 어떤 설정/파라미터 계약인가" 까지만 다루고, 실제 docker-compose 시연·실측은 cousin(다른 phase) [[raw/branch-notes/feature-keycloak-refresh-rotation-and-logout]] (P3A) 에 위임한다.
|
||||
|
||||
- Keycloak refresh token rotation 설정의 **개념·계약**: `Revoke Refresh Token` 토글 동작 + rotation flow(RT 1회 사용 후 무효화·새 토큰 발급) — `KC-RTROT-C1`/`C2`
|
||||
- reuse detection 결과에 대한 **가설·검증 계약** — 후속 RT 유효성, 세션 상태, `Max Reuse=0/1` 차이를 P3A 실행 문서에서 관찰하며 family invalidation을 선결 사실로 두지 않음
|
||||
- `/protocol/openid-connect/revoke` endpoint 의 **RFC 7009 request/response 계약** (`token`/`token_type_hint`, refresh↔access 무효화 SHOULD) — `RFC7009-C1`~`C4`
|
||||
- JWT stateless access token 의 **revoke 즉시성 한계** + 대응 옵션(짧은 TTL / introspection / blacklist / opaque) 트레이드오프 — `RFC7009-C5`~`C7`
|
||||
- **RP-Initiated(front-channel) logout** 파라미터 계약(`id_token_hint`, `post_logout_redirect_uri`, Valid Post Logout Redirect URIs) — `KC-LOGOUT-C1`~`C7`
|
||||
- 짧은 access token TTL 로 revoke 즉시성을 완화하는 **설계 근거**(RFC 자신의 short-lived-token 대안) — `RFC7009-C6`
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- 실제 rotation/revoke/logout 의 docker-compose **시연·실측** — cousin(P3A) [[raw/branch-notes/feature-keycloak-refresh-rotation-and-logout]] 소유
|
||||
- backend 매 요청 **introspection** 호출(stateless 포기 패턴) — 한계만 인지, 채택 안 함
|
||||
- distributed **token blacklist** 캐시(Redis 등) 운영 패턴
|
||||
- Keycloak **custom SPI / event listener**
|
||||
- **back-channel logout 수신** backend 구현과 provider-trigger E2E — 현재 두 refresh note 모두 범위 밖. 필요 시 공식 spec·framework 근거를 갖춘 전용 branch를 새로 만들어야 하며 현재 endpoint 존재를 가정하지 않음
|
||||
- **opaque / reference token** 으로의 전환(Keycloak 지원하나 본 학습 범위 외)
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
- [[raw/official-docs/keycloak-securing-apps-overview-official]] — Keycloak Securing Apps overview (Tokens / revocation)
|
||||
- [[raw/official-docs/oauth-v2-1-draft-ietf]] — OAuth 2.1 (refresh_token rotation 권고)
|
||||
- [[raw/official-docs/oauth2-pkce-rfc-7636]] — PKCE (refresh_token 보안 맥락)
|
||||
- [[raw/official-docs/keycloak-refresh-token-rotation-sessions-official]] — Keycloak Server Administration Guide (§_timeouts + §_refresh_token_rotation). D1(`Revoke Refresh Token` 토글 존재·동작)과 D4(RT 1회 사용 후 invalidate) 를 **부분** 뒷받침. `Refresh Token Max Reuse` 필드와 "family invalidate" 메커니즘은 이 자료에서 확인되지 않음(KC-RTROT-C6) — D1/D4 의 `UNSUPPORTED_DECISION` 라벨은 유지 필요.
|
||||
- [[raw/official-docs/oauth2-token-revocation-rfc-7009]] — RFC 7009 Token Revocation (revoke endpoint 표준 request/response 계약 + self-contained/JWT access token 의 revoke 즉시성 한계의 표준 근거, `RFC7009-C1`~`C6`)
|
||||
- [[raw/official-docs/keycloak-refresh-token-rotation-reuse-admin-official]] — Keycloak Server Admin Guide "Tokens tab" — "Revoke Refresh Token" 설정 공식 정의 확보 + "Refresh Token Max Reuse"/family-invalidate 동작의 verbatim **부재**를 전수 검색으로 확인(negative finding). D1/D4는 여전히 `UNSUPPORTED_DECISION` 유지. ⚠️ 위 `keycloak-refresh-token-rotation-sessions-official` 와 **동일 소스(server_admin Tokens 탭)의 중복 발췌** — 병합/아카이브는 사용자 판단(§보고 참조)
|
||||
- [[raw/official-docs/keycloak-oidc-logout-endpoint-official]] — Keycloak RP-Initiated(front-channel) logout 파라미터 계약(`end_session_endpoint`, `id_token_hint`, `post_logout_redirect_uri`, Backchannel Logout URL). D3 + 구현 가이드 §3 근거 (`KC-LOGOUT-C1`~`C7`)
|
||||
- [[raw/official-docs/oauth2-browser-based-apps-ietf-draft]] — OAuth 2.0 for Browser-Based Apps (IETF BCP). browser-based OAuth client = public client 가 토큰을 브라우저에 보유 → 탈취 위협 배경(D1 `선택 조건`, `OAUTH-BBA-C3`)
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [ ] **Keycloak Realm Settings → Tokens 탭 항목** — 등급: `documented-only`
|
||||
- `Revoke Refresh Token`: **ON** — refresh 사용 후 해당 토큰 무효화
|
||||
- `Refresh Token Max Reuse`: target UI에서 필드 존재를 확인한 뒤 **0과 1을 실험 입력값으로 비교**. 사전에 각 값의 의미를 부여하지 않음
|
||||
- `SSO Session Idle`: 짧게 (예: 30분) — 일정 시간 미사용 시 세션 만료
|
||||
- `SSO Session Max`: 강제 만료 시간 (예: 10h)
|
||||
- `Access Token Lifespan`: 5~15분 (짧을수록 revoke 즉시성 향상)
|
||||
- `Client Session Idle` / `Client Session Max`: client별 override
|
||||
- [ ] **Refresh Token Rotation flow** — 등급: `documented-only`
|
||||
```text
|
||||
1) SPA가 RT_1으로 /token (grant_type=refresh_token) 호출
|
||||
2) Keycloak: RT_1 검증 → invalidate → AT_2 + RT_2 반환
|
||||
3) SPA가 RT_2로 다음 갱신 → RT_2 invalidate → AT_3 + RT_3
|
||||
```
|
||||
- [ ] **Reuse Detection 결과 가설 검증** — 등급: `needs-confirmation`
|
||||
- 공격자가 RT_1을 탈취하고 사용 → AT_2 + RT_2 받음
|
||||
- 정상 사용자가 (모르고) RT_1을 다시 사용 → RT_1 응답과 RT_2의 후속 사용 결과, realm session 상태를 각각 관찰
|
||||
- `Max Reuse=0`과 `1`에서 같은 sequence를 실행해 후속 토큰 무효화 범위를 기록. family 전체 invalidation은 가능한 관찰 결과 중 하나일 뿐 기대값으로 고정하지 않음
|
||||
- [ ] **Revoke endpoint 사용법** — 등급: `documented-only`
|
||||
```text
|
||||
POST /realms/<realm>/protocol/openid-connect/revoke
|
||||
token=<token>
|
||||
token_type_hint=refresh_token (또는 access_token)
|
||||
client_id=<spa-client>
|
||||
```
|
||||
- `token`/`token_type_hint` 파라미터 계약은 RFC 7009 §2.1 표준과 일치 — [[raw/official-docs/oauth2-token-revocation-rfc-7009]] `RFC7009-C3` (Keycloak 이 이 endpoint 를 실제로 RFC 7009 로 문서화하는지는 `RFC7009-C1` 의 "Does not prove" 참조 — 별도 vendor 확인 필요)
|
||||
- refresh_token revoke: RFC 상 SHOULD 로 관련 access token 도 함께 무효화될 수 있음(`RFC7009-C4`) — MUST 아님, AS 지원 여부에 달림
|
||||
- access_token revoke: Keycloak은 introspection 시 invalid 응답, 그러나 **JWT를 stateless로 검증하는 backend는 모름** (`RFC7009-C5`/`C7`, 아래 함정 참조)
|
||||
- [ ] **Logout 시 토큰 정리** — 등급: `documented-only`
|
||||
- (a) Front-channel logout: `/protocol/openid-connect/logout?post_logout_redirect_uri=...&id_token_hint=<id_token>` — 브라우저 redirect로 Keycloak 세션 종료
|
||||
- (b) Back-channel logout: Keycloak client 설정 필드의 존재만 기록. 수신 endpoint와 provider-trigger E2E는 현재 범위에 없고 구현을 가정하지 않음
|
||||
- (c) Refresh token revoke: 명시적으로 `/revoke` 호출
|
||||
- SPA가 메모리에서 토큰 삭제 + cookie clear도 추가
|
||||
- [ ] **함정: JWT access_token stateless 한계** — 등급: `documented-only`
|
||||
- JWT는 자체 서명 검증으로 valid 여부 판단 → backend가 **revoke 사실을 모름** — [[raw/official-docs/oauth2-token-revocation-rfc-7009]] `RFC7009-C5` (self-contained access token 은 AS 와 추가 상호작용 없이 인가 판단)가 표준 근거
|
||||
- access_token 만료(`exp`)까지 backend는 valid로 통과시킴 — `RFC7009-C5` (self-contained token 은 AS 상호작용 없이 검증) + `RFC7009-C4` (access token 무효화는 AS 가 지원할 때만 SHOULD, MUST 아님)
|
||||
- 대응 옵션:
|
||||
1. **짧은 TTL** (5~15분) — 가장 일반적
|
||||
2. **Token Introspection** (`/protocol/openid-connect/token/introspect`) — 매 요청마다 Keycloak에 질의 → stateless 이점 상실, 성능 저하
|
||||
3. **Revocation list / blacklist** — backend가 revoked jti 목록 캐싱 (운영 복잡)
|
||||
4. **Reference token** (opaque) — JWT 대신 opaque token + introspection (Keycloak 지원하나 본 학습 범위 외)
|
||||
- [ ] **함정 정리** — 등급: `documented-only`
|
||||
- `Refresh Token Max Reuse`의 0/양수 의미를 실험 없이 일반화하면 버전별 동작을 잘못 문서화할 수 있음
|
||||
- logout 시 `id_token_hint` 누락하면 prompt 떠서 UX 저하
|
||||
- rotation 활성화 후 SPA 코드가 옛 RT를 재사용하면 실패하거나 후속 RT/세션에 영향이 갈 수 있음 → 정확한 범위는 실행 결과로 기록
|
||||
- back-channel receiver가 없는 현재 scope에서 backend cache 무효화를 보장한다고 쓰지 않음
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
작업하며 떠오른 메모. 자유 형식.
|
||||
|
||||
- Keycloak 25.x 기준 Realm Settings → Tokens 탭 UI 항목은 버전에 따라 라벨이 약간 달라질 수 있음. 실 구현(P3A) 시 정확한 라벨 재확인 필요.
|
||||
- "JWT는 revoke가 안 된다"는 표현은 정확히는 "Keycloak이 revoke를 알리지만 stateless backend가 그 사실을 가져오지 않으면 모른다"가 맞음. 짧은 TTL + rotation 조합으로 실용적 보안 확보.
|
||||
- Keycloak의 `Backchannel Logout URL` 설정 필드 존재와 실제 수신 구현은 별개다. 현재 문서들은 receiver endpoint를 구현·위임하지 않으며, 필요 시 전용 branch에서 spec/framework 지원부터 확인한다.
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
> 추후 면접/회고에서 "왜 이렇게 했나" 답할 근거. 대안과 함께 기록.
|
||||
|
||||
- 2026-05-25 (수정 2026-07-18): `Revoke Refresh Token: ON`은 rotation 실험의 candidate profile로 유지한다. `Refresh Token Max Reuse: 0`은 1과 비교할 **실험 입력값**이며, 다른 값이 탐지를 약화시킨다는 의미는 target-version 결과 전에는 주장하지 않는다.
|
||||
- 2026-05-25: access_token revocation 즉시성은 **짧은 TTL(5~15분)**로 해결. introspection은 stateless 이점 상실 + 성능 저하로 학습 범위에서 권장 안 함.
|
||||
- 2026-07-18: back-channel logout receiver와 provider-trigger E2E는 P2A/P3A 두 refresh note 모두 범위 밖이다. 현재 cousin에 위임하지 않으며, 필요 시 전용 branch를 신설한다.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시.
|
||||
> 본 sub-sub-branch 는 `documented-only`. `Revoke Refresh Token` 토글의 **동작**은 이제 Keycloak 공식 doc 로 뒷받침되나(`KC-RTROT-C1`/`C2`), **`Refresh Token Max Reuse` 필드명**과 **"재사용 시 family 전체 invalidate"** 동작은 Keycloak 26.7.0 Server Admin Guide 전수 검색에서 verbatim 부재 확인(`KC-RTROT-C6`) → 해당 부분만 `UNSUPPORTED_DECISION` 유지, 실측은 P3A cousin 에 위임.
|
||||
> `선택 조건` 열(R2): "이 조건일 때 이 결정, 다른 조건이면 어떤 대안". 분기 없으면 `N/A`.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | **rotation 정책·검증 profile owner** — `Revoke Refresh Token: ON`을 candidate로 두고 `Refresh Token Max Reuse=0/1`을 비교 실험한다. 최종 값과 의미는 target-version 관찰 뒤 확정 | SPA(public client)가 refresh token을 브라우저에 보유하는 P2A/P3A 배치에서 rotation을 평가. BFF/token-mediating backend([[raw/branch-notes/feature-keycloak-bff-vs-spa-direct]])에서는 위협 모델이 달라 재평가. 병렬 refresh가 필요한 client는 `suppress-refresh-token-rotation` executor 예외(`KC-RTROT-C3`) 검토 | `raw/official-docs/keycloak-refresh-token-rotation-sessions-official.md#KC-RTROT-C1`, `#KC-RTROT-C2`, `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C3`, `raw/official-docs/oauth2-browser-based-apps-ietf-draft.md#OAUTH-BBA-C3`. `Max Reuse` 필드·값 의미·family 동작은 `UNSUPPORTED_DECISION`(`KC-RTROT-C6`) | `official-vendor-doc + official-standard + needs-confirmation (Max Reuse semantics)` | target UI/export에서 필드와 내부 key를 확인하고 0/1에서 동일 reuse sequence를 실행. family invalidation 여부는 결과값으로만 기록 |
|
||||
| D2 | access token revocation 즉시성은 짧은 TTL(5~15분) 로 해결, introspection 패턴은 stateless 이점 상실로 권장 안 함 | **stateless JWT 검증(Spring RS)을 유지**하는 한 → 짧은 TTL. "초 단위 즉시 무효화"가 hard requirement 면 → introspection 또는 opaque/reference token(stateless 포기 + 성능 비용) | `raw/official-docs/oauth2-token-revocation-rfc-7009.md#RFC7009-C5` (self-contained/JWT access token 은 AS 와 추가 상호작용 없이 검증 → revoke 즉시 반영 안 될 수 있음) + `#RFC7009-C6` (짧은 수명 access token 이 RFC 자신의 설계 대안) + `#RFC7009-C4` (access token 무효화는 AS 가 access token revocation 을 지원할 때만 SHOULD — 미지원/self-contained 시 즉시 무효화 안 됨). 방향성은 official-standard 근거 보유. **구체적 수치 "5~15분"** 은 RFC 가 분 단위를 제시 안 하므로 `UNSUPPORTED_IMPL_DECISION` (trade-off: 짧을수록 안전하나 refresh 왕복/서버 부하↑ — 5~15분은 임의 균형점) | `official-standard (방향성) + UNSUPPORTED_IMPL_DECISION (TTL 수치)` | Keycloak 이 access token revocation(RFC7009 §2 SHOULD)을 실제 지원하는지 확인 + OWASP/Keycloak 공식 권장 TTL 구간 raw 추가로 수치 보강 |
|
||||
| D3 | RP-Initiated logout 파라미터 계약까지만 소유. back-channel receiver 구현·provider-trigger E2E는 현재 scope 밖이며 endpoint를 가정하지 않음 | 현재 요구는 브라우저 logout과 revoke 계약 학습. backend cache 즉시 무효화가 별도 요구가 되면 전용 branch에서 OIDC Back-Channel Logout spec과 framework 지원을 확보한 뒤 설계 | `raw/official-docs/keycloak-oidc-logout-endpoint-official.md#KC-LOGOUT-C7` (설정 필드 존재만 증명) + `UNSUPPORTED_DECISION` (receiver 미설계) | `official-vendor-doc (field only) + scoped out` | receiver가 구현됐다는 인상을 주는 링크·예상 endpoint를 두지 않음 |
|
||||
| D4 | **reuse 결과 검증 계약** — RT_1 사용 후 무효화·AT_2+RT_2 발급까지는 공식 계약, RT_1 재사용 응답과 RT_2/realm session 상태는 관찰 항목 | D1 profile의 0/1 각각에 동일 sequence 적용. family 전체 invalidation은 가능한 결과 중 하나이며 expected fact가 아님 | `raw/official-docs/keycloak-refresh-token-rotation-sessions-official.md#KC-RTROT-C2` + `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C3` + `UNSUPPORTED_DECISION`(`KC-RTROT-C6`) | `official-vendor-doc (초기 rotation) + needs-confirmation (reuse impact)` | P3A 실행 owner가 RT_1 재사용 status, RT_2 후속 status, session 상태를 분리 기록해야 함 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> *결정 (Decisions)* 이 "*무엇* 을 할 것인가" 라면, 본 §는 "*어디에 어떻게* 구현될 것인가" 의 사전 명세 — 다음 구현자(P3A cousin)가 되묻지 않아도 설정·호출을 작성할 수 있는 수준. 본 노트는 `documented-only` 이므로 각 항목은 **설정/파라미터 계약**까지이며, 실측 승격은 Claims To Verify + P3A cousin 소관.
|
||||
>
|
||||
> **3-rule**: (R1) 각 cell 은 Decision ID + Supporting Claim ID trace, (R2) 근거 없는 detail 은 `UNSUPPORTED_IMPL_DECISION` + trade-off 한 줄, (R3) 본 branch 결정 범위 밖은 남기지 않음.
|
||||
|
||||
### 1. Keycloak Realm Settings — rotation & timeout 설정 계약
|
||||
|
||||
> **Trace**: D1(`KC-RTROT-C1`/`C2`) + D2(`KC-RTROT-C4`) — Realm Settings → Sessions/Tokens 탭.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `Refresh Token Max Reuse` 필드명·0/1 의미·reuse 후 영향(`KC-RTROT-C6` doc text 부재 — admin UI/export와 실험 필요) / `Access Token Lifespan`의 "5~15분" 수치(RFC·Keycloak doc 모두 분 단위 미제시).
|
||||
|
||||
| 설정 | 위치 (Realm Settings) | 값 | 근거 / 상태 |
|
||||
|---|---|---|---|
|
||||
| `Revoke Refresh Token` | Sessions/Tokens 탭 | **Enabled** | `KC-RTROT-C1` — documented |
|
||||
| `Refresh Token Max Reuse` | Tokens 탭 (노출 여부 포함 확인) | **0과 1을 각각 실험** | `UNSUPPORTED_IMPL_DECISION` — 필드·값 의미 doc text 부재(`KC-RTROT-C6`), UI/export + runtime 비교 |
|
||||
| `Access Token Lifespan` | Tokens 탭 | 5~15분 | `KC-RTROT-C4`(설정 존재) + `UNSUPPORTED_IMPL_DECISION`(수치) |
|
||||
| `SSO Session Idle` / `SSO Session Max` | Sessions 탭 | 프로젝트값(예: 30m / 10h) | `KC-RTROT-C4` — documented |
|
||||
| `Client Session Idle` / `Client Session Max` | Sessions 탭 | SSO 값보다 짧게(client override) | `KC-RTROT-C4` — documented |
|
||||
|
||||
### 2. Revoke endpoint 호출 계약 (RFC 7009)
|
||||
|
||||
> **Trace**: D2 + `RFC7009-C1`~`C4`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: Keycloak 이 이 endpoint 를 *RFC 7009 로* 문서화/준수한다는 명시는 vendor 확인 필요(`RFC7009-C1` "Does not prove"). 여기서는 RFC 표준 계약 형태만 고정.
|
||||
|
||||
```text
|
||||
POST /realms/<realm>/protocol/openid-connect/revoke
|
||||
token=<token> # REQUIRED (RFC7009-C2)
|
||||
token_type_hint=refresh_token|access_token # OPTIONAL, 서버 조회 최적화용 (RFC7009-C3)
|
||||
client_id=<spa-client> # client 인증
|
||||
```
|
||||
|
||||
- refresh_token revoke → AS 가 access token revocation 을 지원하면 관련 access token 도 **SHOULD** 함께 무효화(`RFC7009-C4` — MUST 아님).
|
||||
- access_token revoke → Keycloak introspection 은 invalid 로 응답하나, JWT 를 stateless 로 검증하는 backend 는 그 사실을 모름(§4 참조).
|
||||
|
||||
### 3. Front-channel(RP-Initiated) logout 파라미터 계약
|
||||
|
||||
> **Trace**: `KC-LOGOUT-C1`~`C7` (In-scope logout; back-channel *수신* 은 D3 로 out of scope).
|
||||
|
||||
| 요소 | 계약 | 근거 |
|
||||
|---|---|---|
|
||||
| endpoint | `/realms/<realm>/protocol/openid-connect/logout` (= `end_session_endpoint`) | `KC-LOGOUT-C1`/`C2` |
|
||||
| `id_token_hint` | 없으면 로그아웃 confirm UI 가 뜰 수 있음 → UX 위해 전달 권장 | `KC-LOGOUT-C3` |
|
||||
| `post_logout_redirect_uri` | 제공 시 자동 redirect. 단 `client_id` 또는 `id_token_hint` **동반 필수** + client 의 `Valid Post Logout Redirect URIs` 와 매칭 필요 | `KC-LOGOUT-C4`/`C5`/`C6` |
|
||||
| `Backchannel Logout URL` (client 설정) | **필드 정의만** in-scope. receiver endpoint와 provider-trigger E2E는 현재 존재를 가정하지 않으며 별도 요구 시 전용 branch 필요 | `KC-LOGOUT-C7` |
|
||||
|
||||
### 4. Stateless JWT access token 즉시성 완화 config
|
||||
|
||||
> **Trace**: D2 + `RFC7009-C5`/`C6`/`C7`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: TTL 수치(§1 과 동일 trade-off).
|
||||
|
||||
- backend(Spring RS)는 서명 + `iss`/`aud`/`exp` 만 검증 → revoke 사실을 모름(`RFC7009-C5`). `exp` 만료까지 valid 통과(`RFC7009-C5` self-contained + `RFC7009-C4` 조건부 SHOULD).
|
||||
- 짧은 TTL = RFC 자신이 제시하는 설계 대안(`RFC7009-C6`). 채택.
|
||||
|
||||
| 대응 옵션 | stateless 유지? | 비용 | 본 노트 판정 |
|
||||
|---|---|---|---|
|
||||
| 짧은 TTL (5~15분) | ✅ 유지 | revoke 후 최대 TTL 만큼 노출 창 | **채택** (`RFC7009-C6`) |
|
||||
| Token Introspection (매 요청) | ❌ 포기 | 매 요청 Keycloak 왕복·성능↓ | 한계만 인지, 미채택 |
|
||||
| Revocation list / jti blacklist | 부분 | backend 캐시 운영 복잡 | out of scope |
|
||||
| Opaque/reference token | ❌ 포기 | introspection 상시 | out of scope |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4 캡처용. 정상 경로 외에 *구현 중 부딪힐* 실패/엣지/다른 계약 의존을 미리 열거.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **RT 캐시 후 옛 RT 재사용** → RT_1 요청이 실패하고 후속 RT/세션에도 영향이 갈 수 있음. 정확한 범위는 D4 실험으로 관찰. 클라이언트는 in-flight refresh를 단일 진입점으로 직렬화한다.
|
||||
- **동시 silent renew race** → 동일 RT 동시 제출은 D4 관찰을 오염시킬 수 있음. 0/1 각 실험에서 단일 refresh 진입점을 보장하고 manual 시연 시 silent renew를 일시 중단한다.
|
||||
- **logout `id_token_hint` 누락** → confirm prompt 로 UX 저하(`KC-LOGOUT-C3`). 기대: id_token 보관 후 전달.
|
||||
- **`post_logout_redirect_uri` 미등록** → `Valid Post Logout Redirect URIs` 매칭 실패로 redirect 거부(`KC-LOGOUT-C6`). 기대: client 설정에 사전 등록.
|
||||
- **access_token revoke 직후 만료 전 호출** → 200 통과(stateless JWT, `RFC7009-C5`) — **함정(의도된 한계)**. 기대: `exp` 까지 유효, TTL 후 401.
|
||||
- **back-channel receiver 부재** → 현재 범위에서는 backend session/cache의 즉시 무효화를 보장하지 않는다. 필요 시 전용 branch를 생성한다.
|
||||
- **다른 계약 의존**:
|
||||
- 부모 [[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]] — P2A 배치(SPA-direct, no Google) 컨텍스트를 consume. 배치가 BFF 로 바뀌면 D1 전제(브라우저가 RT 보유) 붕괴.
|
||||
- [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] `D1`/`D4` — RT 를 브라우저 어디에 저장하느냐(그 브랜치 D1: AT 메모리 + RT httpOnly cookie)가 탈취 위험/rotation 필요성의 **전제**이며, 그 브랜치 D4 가 "refresh_token 은 rotation 에 의존" 을 명시. 저장 결정이 바뀌면 본 브랜치 위협모델 영향.
|
||||
- [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] `D1` — backend 가 `iss`+signature+`exp`+`aud` 4종 검증(그 브랜치 D1)이 D2 stateless 한계의 전제. `exp` 만료 시 401 동작이 §4 의 근거.
|
||||
- [[raw/branch-notes/feature-keycloak-pkce-flow-stages]] — 최초 토큰(AT_1/RT_1) 발급 흐름(PKCE)을 consume — rotation 은 그 이후 단계.
|
||||
- cousin(다른 phase) [[raw/branch-notes/feature-keycloak-refresh-rotation-and-logout]] (P3A) — 본 개념 계약의 **실측·시연** 소유. 본 노트 = 개념/계약, 그쪽 = 실행.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서나 사례는 근거지만, 내 프로젝트에서의 동작을 자동으로 보장하지 않는다.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| Keycloak Realm Settings → Tokens 탭에 `Revoke Refresh Token` 토글과 `Refresh Token Max Reuse` 입력이 정확히 그 라벨로 존재 | Keycloak 버전마다 admin UI 라벨이 달라질 수 있음; cited raw 에서 verbatim 미회수 | Keycloak 25.x docker 컨테이너 실행 후 admin UI 캡처 + Server Admin Guide raw source 발췌 추가 | `needs-confirmation` |
|
||||
| `Refresh Token Max Reuse=0`과 `1`에서 RT 재사용 결과가 어떻게 다른지(후속 RT·realm session 포함) | 필드·값 의미와 family invalidation 메커니즘이 cited raw에 verbatim 없음 | 각 값으로 realm을 재설정한 뒤 RT_1 사용→RT_2 발급→RT_1 재사용→RT_2 후속 사용→session 상태를 동일 순서로 기록 | `needs-confirmation` |
|
||||
| `/protocol/openid-connect/revoke` 엔드포인트가 RFC 7009 Token Revocation 을 준수한다 | RFC 7009 의 raw source 부재; Keycloak 의 RFC 준수 여부 verbatim 인용 없음 | RFC 7009 raw 발췌 후 Keycloak Server Admin Guide 의 "Token Revocation" 섹션과 cross-check | `needs-confirmation` |
|
||||
| JWT stateless backend 가 access_token revoke 사실을 모름 — 만료 전 검증 통과 | OAuth 2.1 / PKCE RFC 에 stateless JWT introspection 트레이드오프의 verbatim 인용 없음 | Spring Security Resource Server 로 JWT 검증 설정 후, revoke 직후 동일 token 으로 호출 → 200 응답 확인 (TTL 5분) | `planned` |
|
||||
| RP-Initiated logout 파라미터가 target Keycloak에서 문서 계약대로 동작하는지 | 공식 파라미터 계약은 있으나 runtime 미검증. back-channel receiver는 본 claim과 scope에 포함하지 않음 | front-channel logout만 실행해 session 종료·redirect를 확인. back-channel 요구가 생기면 전용 branch에서 별도 검증 | `needs-confirmation` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 이슈 1: rotation 상태에서 SPA가 옛 refresh_token을 재시도하면 정상 사용자 흐름도 실패할 수 있음.
|
||||
- 원인 가설: reuse 처리 범위가 정상/공격 주체를 구분하지 않을 수 있음. 후속 RT·session 영향은 target-version 실험 전 확정하지 않음
|
||||
- 시도: (구현 없음)
|
||||
- 해결: SPA가 refresh 진행 중에는 단일 진입점으로 직렬화 (in-flight refresh promise 공유) — `documented-only`
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/keycloak-refresh-token-rotation-reuse-admin-official]]
|
||||
- [[raw/official-docs/keycloak-refresh-token-rotation-sessions-official]]
|
||||
- [[raw/official-docs/oauth-v2-1-draft-ietf]]
|
||||
- [[raw/official-docs/oauth2-token-revocation-rfc-7009]]
|
||||
- [[raw/official-docs/oidc-client-ts-library]]
|
||||
- [[raw/official-docs/owasp-html5-storage-xss-spa]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
> 본 sub-sub-branch 는 leaf — 자식 자료 없음. Phase 3 P3A 실 구현 또는 외부 산출물 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 오류 기록 (이 sub-sub-branch 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 documented-only 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — Phase 3 실 구현 단계에 누적)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 머지/종료 시점에 채움. `/ingest`가 이 섹션을 기준으로 wiki/projects/에 추출.
|
||||
|
||||
- PR 링크: (미구현 — 문서까지만)
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: 없음 (P2A는 `documented-only` 범위)
|
||||
- **wiki 추출 대상**: 현 단계 없음. 추후 `wiki/concepts/refresh-token-rotation-revocation.md`로 합성 후보 (다른 패턴과 공통).
|
||||
- **추출하지 않을 항목**: P2A 구현 없음. `documented-only` 유지.
|
||||
+341
@@ -0,0 +1,341 @@
|
||||
---
|
||||
title: branch / feature-keycloak-reverse-proxy-headers (P3B Keycloak reverse proxy 설정 — KC_PROXY_HEADERS + KC_HOSTNAME)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-CHILD-2D084935
|
||||
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-reverse-proxy-headers
|
||||
parent_branch: feature-keycloak-patterns
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, p3b, reverse-proxy, keycloak-hostname, nginx, caddy]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 87115ea4c7f2ee6851a0dd97401058806a1273783e4b25ece55613fbb67c8f9c
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-reverse-proxy-headers (P3B Keycloak reverse proxy 설정 — KC_PROXY_HEADERS + KC_HOSTNAME)
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/branch-notes/feature-keycloak-patterns]] governance Work Item의 child. P3B 의미 계약은 [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]]를 참조한다. **Cloudflare Tunnel / nginx / Caddy 뒤에 Keycloak이 위치할 때 redirect URL이 internal hostname(`keycloak:8080`)으로 떨어지는 함정** 해결.
|
||||
> 본 sub-sub-branch는 **문서까지만** — 실 Keycloak 구동 / nginx config 적용은 진행하지 않음. 등급 `documented-only`.
|
||||
> `status_label`: `in-progress`
|
||||
|
||||
<!-- 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 Google federation 변형의 reverse-proxy header 경계에 적용한다 | [[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 -->
|
||||
## 목표
|
||||
|
||||
Keycloak이 reverse proxy 뒤에 있을 때 디폴트로는 `Host` 헤더와 `X-Forwarded-*` 헤더를 신뢰하지 않는다. 그 결과 issuer URL / authorization endpoint / token endpoint 등이 internal hostname(`http://keycloak:8080`)으로 발급되어 다음 함정이 발생한다:
|
||||
- SPA가 받는 `iss` claim이 public URL이 아닌 internal URL → JWT 검증 실패
|
||||
- Google이 redirect 받을 callback URL이 internal → Google이 도달 불가
|
||||
- discovery document(`/.well-known/openid-configuration`)의 모든 endpoint가 internal URL
|
||||
|
||||
해결은 Keycloak에 **proxy 환경임을 명시** + **canonical public hostname을 강제** 하는 것.
|
||||
|
||||
면접에서 답해야 할 질문:
|
||||
1. `KC_PROXY_HEADERS`와 `KC_HOSTNAME`의 차이는? → 전자는 proxy가 보낸 헤더를 신뢰할지(어떤 헤더 포맷인지), 후자는 issuer URL 강제 override.
|
||||
2. `KC_HOSTNAME_STRICT`는 왜 필요한가? → 클라이언트가 보낸 Host 헤더로 issuer가 결정되는 디폴트 동작을 막아 issuer URL을 고정.
|
||||
3. nginx vs Caddy 선택 기준은? → Caddy는 reverse_proxy directive가 X-Forwarded-* 자동 설정, nginx는 명시 필요.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- Keycloak 환경변수: `KC_PROXY_HEADERS`, `KC_HOSTNAME`, `KC_HOSTNAME_STRICT`, `KC_HTTP_RELATIVE_PATH`, `KC_HTTP_ENABLED`, `KC_PROXY_TRUSTED_ADDRESSES`
|
||||
- nginx config 예시 (X-Forwarded-For / X-Forwarded-Proto / Host 명시)
|
||||
- Caddy config 예시 (reverse_proxy directive — X-Forwarded-* 자동)
|
||||
- path-prefix 라우팅 (`/keycloak/*`) 시 `KC_HTTP_RELATIVE_PATH` 설정
|
||||
- Cloudflare Tunnel origin이 HTTP일 때 X-Forwarded-Proto: https 주입 흐름
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 실 Keycloak realm / client / IdP 등록 (별도 sub-sub-branch `-6-3`)
|
||||
- HTTPS termination 자체 (별도 sub-sub-branch `-6-4`)
|
||||
- Apache HTTP Server 또는 HAProxy reverse proxy 옵션
|
||||
- Keycloak admin console 보안 분리 (`KC_HOSTNAME_ADMIN`)
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
- [[raw/official-docs/keycloak-reverseproxy-official]] — Keycloak behind reverse proxy 공식
|
||||
- [[raw/official-docs/keycloak-hostname-configuration]] — Keycloak hostname configuration 공식
|
||||
|
||||
## 관련 sub-branch
|
||||
|
||||
- [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]] (부모, P3B Single EC2 + Google federation)
|
||||
- [[raw/branch-notes/feature-keycloak-public-domain-tunneling]] — 학습 환경 public 도메인 확보 (ngrok / Cloudflare Tunnel)
|
||||
- [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]] — Google OAuth client 등록 + ngrok URL 변경 시 갱신
|
||||
- [[raw/branch-notes/feature-keycloak-https-termination-caddy-nginx]] — HTTPS termination (Caddy vs nginx + Let's Encrypt)
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기.
|
||||
|
||||
- [ ] **`KC_PROXY_HEADERS` 모드 정리** — `xforwarded` (X-Forwarded-* 헤더 신뢰) vs `forwarded` (RFC 7239 Forwarded 헤더 신뢰) 차이 — 등급: `planned`
|
||||
- [ ] **`KC_HOSTNAME=<public-domain>` 설정** — Keycloak이 발급하는 issuer / authorization / token URL을 이 값으로 고정 — 등급: `planned`
|
||||
- [ ] **`KC_HOSTNAME_STRICT=true`** — 클라이언트 Host 헤더 무시, `KC_HOSTNAME` 값 강제 사용 — 등급: `planned`
|
||||
- [ ] **`KC_HTTP_RELATIVE_PATH=/keycloak`** — path-prefix 라우팅 시 (nginx가 `/keycloak/*` → Keycloak 8080) — 등급: `planned`
|
||||
- [ ] **`KC_HTTP_ENABLED=true` + `KC_PROXY_TRUSTED_ADDRESSES=127.0.0.1`** — reverse proxy가 HTTPS 종단 후 Keycloak에 HTTP forward, proxy header spoofing 방지 — 등급: `planned`
|
||||
- [ ] **nginx config 예시 작성** — `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; X-Forwarded-Proto $scheme; Host $host;` — 등급: `planned`
|
||||
- [ ] **Caddy config 예시 작성** — `reverse_proxy localhost:8080` (X-Forwarded-* 자동 설정 동작 검증) — 등급: `planned`
|
||||
- [ ] **Cloudflare Tunnel + Keycloak 조합 시 헤더 흐름** — Cloudflare edge에서 X-Forwarded-Proto: https 자동 주입, origin은 HTTP로 받음 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- Keycloak 25.x 기준 `--proxy <mode>` 옵션은 deprecated → `KC_PROXY_HEADERS=xforwarded|forwarded` 사용.
|
||||
- `KC_HOSTNAME_STRICT_BACKCHANNEL` 옵션은 server-to-server 호출 시 internal hostname 사용 허용 여부. 단일 EC2 + Cloudflare Tunnel 조합에서는 `false` 유지 (모두 public hostname 통일).
|
||||
- `KC_PROXY_TRUSTED_ADDRESSES`는 25.x에서 추가된 옵션. proxy header를 보낸 source IP를 화이트리스트화 → header spoofing 방지. 단일 EC2 nginx 시나리오는 `127.0.0.1`.
|
||||
- Cloudflare Tunnel origin이 `http://localhost:8080`이면 edge에서 받은 HTTPS 정보는 `X-Forwarded-Proto: https` 헤더로 전달 → `KC_PROXY_HEADERS=xforwarded` 필요.
|
||||
|
||||
### nginx config 예시 초안
|
||||
|
||||
```
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name kc.example.com;
|
||||
|
||||
location /keycloak/ {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
대응 Keycloak 환경변수:
|
||||
- `KC_PROXY_HEADERS=xforwarded`
|
||||
- `KC_HOSTNAME=https://kc.example.com`
|
||||
- `KC_HOSTNAME_STRICT=true`
|
||||
- `KC_HTTP_RELATIVE_PATH=/keycloak`
|
||||
- `KC_HTTP_ENABLED=true`
|
||||
- `KC_PROXY_TRUSTED_ADDRESSES=127.0.0.1`
|
||||
|
||||
### Caddy config 예시 초안
|
||||
|
||||
```
|
||||
kc.example.com {
|
||||
handle /keycloak/* {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
method B(`KC_HTTP_RELATIVE_PATH=/keycloak`)에서는 `handle`이 `/keycloak` prefix를 보존하도록 구성한다. `handle_path`는 prefix를 strip하므로 이 실행 예시에 사용하지 않는다. Caddy가 자동 생성하는 X-Forwarded-* 헤더의 정확한 집합은 별도 실측 대상이다.
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- **2026-05-25**: P3B는 `KC_PROXY_HEADERS=xforwarded` 채택. 이유: nginx / Caddy / Cloudflare Tunnel 모두 X-Forwarded-* 헤더가 디폴트 (RFC 7239 Forwarded 헤더는 덜 보편적).
|
||||
- **2026-05-25**: `KC_HOSTNAME_STRICT=true` 강제. 이유: 클라이언트 Host 헤더에 의존하면 multi-host 시나리오에서 issuer URL이 갈리고, Google brokering callback URL exact match 정책과 충돌.
|
||||
- **2026-05-25**: path-prefix 라우팅(`/keycloak/*`) 채택. 이유: 단일 EC2에 SPA(`/`) + API(`/api/*`) + Keycloak(`/keycloak/*`)을 한 도메인에 묶기 위함. 부모 P3B 다이어그램과 일치.
|
||||
- **2026-05-25**: 학습 단계는 **Caddy 우선** (1줄 config + Let's Encrypt 자동). nginx는 운영 환경 비교 대상으로만 기재. 사유 상세는 sub-sub-branch `-6-4`에서 추가 논의.
|
||||
- **2026-05-25**: 본 sub-sub-branch 전체 등급 `documented-only`. 실 Keycloak 구동 / 환경변수 적용 / nginx 또는 Caddy 동작 검증은 P3A 완료 후 선택적 확장 시점에 재검토.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. Keycloak vendor doc 으로 직접 뒷받침되는 결정 (D1, D2, D3, D6) 과 운영 환경 비교 / scoping 결정 (D4, D5) 을 분리.
|
||||
> `선택 조건` 열(R2)은 "이 조건일 때 이 결정, 다른 조건이면 어떤 대안" — 분기 없으면 `N/A`. (2026-07-18 `/branch-spec`: 기존 D1~D7 의 Decision / Supporting Claims / Evidence Strength / Open Risk 셀은 verbatim 보존하고 `선택 조건` 열만 신규 추가. D2·D4·D6·D7 은 다른 owner 브랜치에 위임되는 관심사를 선택 조건 셀에 명시 — 상세는 §Audit & Findings.)
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | P3B 는 `KC_PROXY_HEADERS=xforwarded` 채택 (nginx / Caddy / Cloudflare Tunnel 모두 X-Forwarded-* 가 디폴트, RFC 7239 Forwarded 는 덜 보편적) | proxy 가 `X-Forwarded-*` 를 emit 할 때 (nginx / Caddy / Cloudflare Tunnel). **대안 `forwarded`**: proxy 가 RFC 7239 표준 `Forwarded` 헤더를 emit 할 때 (`KC-RP-C1` — 덜 보편적). **미설정은 선택지 아님**: reverse proxy 없이 직결일 때만 유효한데 P3B 는 항상 proxy 뒤 | `raw/official-docs/keycloak-reverseproxy-official.md#KC-RP-C2` (xforwarded 가 X-Forwarded-For/Proto/Host/Port/Prefix 파싱), `raw/official-docs/keycloak-reverseproxy-official.md#KC-RP-C1` (forwarded 가 RFC 7239 파싱 — 비교 baseline) | `official-vendor-doc` (Keycloak 공식이 두 옵션 모두 명시) | 헤더 파싱 활성화만으로 spoofing 방어 안 됨 (`KC-RP-C2` does-not-prove) — `KC_PROXY_TRUSTED_ADDRESSES` 별도 필수 |
|
||||
| D2 | `KC_HOSTNAME_STRICT=true` 강제 (클라이언트 Host 헤더 무시, issuer URL 고정) | production / multi-host 시나리오 항상 (`KC-HOST-C5` 기본 true). **대안 (strict 완화)**: reverse proxy 가 Host header 를 overwrite 하는 경우만 예외 (`KC-HOST-C5` 예외 절). ⚠️ **`KC_HOSTNAME` 값 결정의 owner 는 [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]]** — 본 branch 는 proxy-headers 와 hostname-strict 의 *상호작용*만 소유 | `raw/official-docs/keycloak-hostname-configuration.md#KC-HOST-C2` (hostname 옵션 의무화 + dynamic URL resolution 차단), `raw/official-docs/keycloak-hostname-configuration.md#KC-HOST-C3` (fraudulent issuer 방지 보안 목적), `raw/official-docs/keycloak-hostname-configuration.md#KC-HOST-C5` (`hostname-strict` 기본 true, production 항상 true 권장) | `official-vendor-doc` (Keycloak hostname-v2 공식이 strict 강제를 보안 목적으로 명시) | reverse proxy 가 Host header 를 overwrite 하는 경우의 예외 처리 (`KC-HOST-C5` 의 예외 절 — "unless your reverse proxy overwrites the Host header") 가 nginx/Caddy 각각의 default 동작과 일치하는지 별도 검증 |
|
||||
| D3 | path-prefix 라우팅 (`/keycloak/*`) 채택 + `KC_HTTP_RELATIVE_PATH=/keycloak` 설정 — SPA(`/`) + API(`/api/*`) + Keycloak(`/keycloak/*`) 한 도메인 묶기 | 한 도메인에 SPA + API + Keycloak 를 subpath 로 묶을 때 **method B (Keycloak `http-relative-path`)**. **대안 method A**: proxy 가 `X-Forwarded-Prefix` 헤더 주입 (`KC-RP-C6` — Keycloak 은 context path 무변경). **subpath 불요**: Keycloak 전용 서브도메인(`kc.example.com/`)이면 relative-path 자체가 불필요 | `raw/official-docs/keycloak-reverseproxy-official.md#KC-RP-C6` (subpath 노출 방법 — proxy 의 `X-Forwarded-Prefix` 또는 Keycloak `http-relative-path` 둘 중 선택) | `official-vendor-doc` (subpath 노출 공식 옵션 2종 명시) | 두 방법 (A: proxy prefix 주입 vs B: Keycloak relative path) 의 정확한 trade-off (admin console URL, OIDC discovery 경로 영향) 본 인용 부분만 (`KC-RP-C6` does-not-prove) — admin console URL 변경 함정 별도 검증 |
|
||||
| D4 | 학습 단계는 Caddy 우선 (1줄 config + Let's Encrypt 자동), nginx 는 운영 환경 비교 대상 | 학습 단계 (config 단순성 우선). **대안 nginx**: 운영 / 기존 nginx 스택 재사용 시. ⚠️ **HTTPS termination + Caddy vs nginx 선택의 owner 는 [[raw/branch-notes/feature-keycloak-https-termination-caddy-nginx]]** — 본 branch 는 그 선택에 delegate (proxy 가 emit 하는 헤더 계약만 소유) | UNSUPPORTED_DECISION (Caddy `reverse_proxy` directive 의 X-Forwarded-* 자동 설정 동작에 대한 공식 vendor doc raw 보존 부재 — 자체 메모) | UNSUPPORTED_DECISION | Caddy 가 emit 하는 X-Forwarded-* 헤더 셋이 Keycloak `xforwarded` 파싱 기대치 (5종 헤더) 와 정확히 일치하는지 미검증 |
|
||||
| D5 | 본 sub-sub 전체 등급 `documented-only` (실 Keycloak 구동 / 환경변수 적용 / nginx 또는 Caddy 동작 검증은 P3A 완료 후) | N/A (organizational scoping — 분기 없음). P3A 완료 후 선택적 확장 시점에 실 구동 등급으로 재검토 | UNSUPPORTED_DECISION (학습 단계 scoping — 외부 vendor 인용 불요) | N/A (organizational decision) | 환경변수 조합의 실제 동작 (특히 `KC_HTTP_ENABLED=true` 누락 시 부팅 실패) 이 문서상의 가정과 어긋날 수 있음 |
|
||||
| D6 | `KC_HTTP_ENABLED=true` + `KC_PROXY_TRUSTED_ADDRESSES=127.0.0.1` 채택 (reverse proxy HTTPS 종단 후 Keycloak 에 HTTP forward + proxy header spoofing 방지) | TLS edge termination(proxy 가 HTTPS 종단) 시 `KC_HTTP_ENABLED=true` 필수 (`KC-RP-C4`). **대안 (http-enabled 불요)**: TLS passthrough 모드. ⚠️ **`KC_PROXY_TRUSTED_ADDRESSES`(proxy-header spoofing 방어)의 owner 는 [[raw/branch-notes/feature-keycloak-header-spoofing-defense]] D6** — source doc `keycloak-reverseproxy-official.md` Parent 표가 그 branch 를 근거 소유자로 지정. 본 branch 는 `KC_HTTP_ENABLED`(TLS-edge 결과)만 소유, trusted-addresses 는 §Audit A1 로 위임 | `raw/official-docs/keycloak-reverseproxy-official.md#KC-RP-C4` (TLS edge termination 시 `http-enabled` 필수), `raw/official-docs/keycloak-reverseproxy-official.md#KC-RP-C5` (`--proxy-trusted-addresses=192.168.0.32,127.0.0.0/8` 예시), `raw/official-docs/keycloak-reverseproxy-official.md#KC-RP-C3` (header spoofing 공식 경고) | `official-vendor-doc` (Keycloak 공식이 3개 항목 모두 verbatim 명시) | TLS passthrough 모드 (단일 EC2 에서 향후 변경 가능성) 에서는 `http-enabled` 불필요 — 본 인용 범위 밖 (`KC-RP-C4` does-not-prove) |
|
||||
| D7 | `KC_HOSTNAME=https://kc.example.com` (full URL with `https://` prefix) — scheme 없으면 일부 endpoint 가 http 로 발급 | `hostname-backchannel-dynamic=true` 시 full URL 필수 (`KC-HOST-C4`). **backchannel-dynamic=false(단일 EC2)** 에서 `https://` prefix 강제 여부는 **미검증** → §구현 가이드 `UNSUPPORTED_IMPL_DECISION`. ⚠️ hostname 값 owner 는 [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] | `raw/official-docs/keycloak-hostname-configuration.md#KC-HOST-C4` (`hostname-backchannel-dynamic=true` 시 hostname 옵션은 full URL 로 지정해야 함) | `official-vendor-doc` (조건부 — `hostname-backchannel-dynamic=true` 시) | full URL 의 정확한 schema/port 처리 (`https://` 의무 여부) 본 인용 범위 밖 (`KC-HOST-C4` does-not-prove). `hostname-backchannel-dynamic=false` 인 단일 EC2 에서도 `https://` prefix 가 강제되는지 미검증 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 본 sub-sub 는 `documented-only`(D5) — 여기서 "구현"은 각 환경변수·proxy 헤더 설정의 **구성 레시피**(다음 구현자가 되묻지 않고 config 를 작성할 수준)를 뜻한다. 본 branch 의 in-scope 결정(D1 proxy-header 파싱 모드 · D3 subpath 노출 · D6 의 `KC_HTTP_ENABLED` · D7 hostname full-URL)에서 도출되는 detail 만 적고, 각 cell 을 Decision ID + Supporting Claim ID 로 trace 한다.
|
||||
> **OUT_OF_BRANCH_SCOPE 정제(R3, CLAUDE.md §15.5)**: (1) HTTPS termination 자체(Caddy vs nginx 선택, cert 발급/갱신)는 [[raw/branch-notes/feature-keycloak-https-termination-caddy-nginx]] 소유 — 본 § 은 proxy 가 **emit 하는 헤더 계약**만 다루고 TLS 설정 라인은 남기지 않는다. (2) `KC_PROXY_TRUSTED_ADDRESSES`(spoofing 방어)는 [[raw/branch-notes/feature-keycloak-header-spoofing-defense]] D6 소유(§Audit A1) — 본 § 은 `KC_HTTP_ENABLED` 만. (3) `KC_HOSTNAME` **값** 결정과 `iss` 검증은 [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] 소유 — 본 § 은 proxy-headers 와 hostname 의 *상호작용*만.
|
||||
|
||||
### 0. 환경변수 ↔ 결정 ↔ 소유 매핑 (요약)
|
||||
|
||||
> In-scope #1(환경변수 정리)의 종결 표. 각 KC_* 키가 어느 결정에서 나오고, 본 branch 소유인지 위임인지 한눈에.
|
||||
|
||||
| 환경변수 | 값 (P3B) | Decision | 근거 Claim | 소유 |
|
||||
|---|---|---|---|---|
|
||||
| `KC_PROXY_HEADERS` | `xforwarded` | D1 | `KC-RP-C2` | **본 branch (owner)** |
|
||||
| `KC_HTTP_RELATIVE_PATH` | `/keycloak` | D3 | `KC-RP-C6` (method B) | **본 branch (owner)** |
|
||||
| `KC_HTTP_ENABLED` | `true` | D6 | `KC-RP-C4` | **본 branch (owner)** |
|
||||
| `KC_HOSTNAME` | `https://kc.example.com` | D7 | `KC-HOST-C4` (조건부) | **값 = iss-claim-hostname-mismatch**, 본 branch 는 scheme/proxy 상호작용만 |
|
||||
| `KC_HOSTNAME_STRICT` | `true` | D2 | `KC-HOST-C5` | **값 = iss-claim-hostname-mismatch**, 본 branch 는 proxy 예외절 검증 |
|
||||
| `KC_PROXY_TRUSTED_ADDRESSES` | `127.0.0.1` | D6 | `KC-RP-C5` | **header-spoofing-defense D6 (위임, §Audit A1)** |
|
||||
|
||||
### 1. `KC_PROXY_HEADERS=xforwarded` — 신뢰할 헤더 5종 계약 (D1)
|
||||
|
||||
> **Trace**: D1 / `keycloak-reverseproxy-official#KC-RP-C2`(xforwarded 가 `X-Forwarded-For`/`-Proto`/`-Host`/`-Port`/`-Prefix` 5종 파싱), `#KC-RP-C1`(forwarded=RFC 7239 — 비교 baseline). proxy 측 헤더 주입은 §진행 중 메모의 nginx/Caddy config 초안이 실체.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (a) **Caddy `reverse_proxy` 가 자동으로 emit 하는 X-Forwarded-* 헤더 셋**이 Keycloak `xforwarded` 파싱 기대치(5종)와 정확히 일치하는지 — Caddy 공식 vendor doc raw 미보존(D4 UNSUPPORTED). trade-off: nginx 는 `proxy_set_header` 로 5종을 **명시**하므로 결정론적이나, Caddy 는 "자동" 이 5종 전체를 포함한다는 근거가 본 repo 에 없음 → §Claims To Verify 로 실측 위임. (b) `X-Forwarded-Port` / `X-Forwarded-Prefix` 를 nginx config 초안이 **누락** — 5종 중 3종(For/Proto/Host)만 명시. Port/Prefix 누락 시 Keycloak 이 기본 port/무-prefix 로 추정하는지 미검증.
|
||||
|
||||
| 헤더 | nginx (명시 필요) | Caddy (자동 주장) | Keycloak 소비처 |
|
||||
|---|---|---|---|
|
||||
| `X-Forwarded-For` | `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;` | 자동 | client IP (access log, trusted-addresses 판정) |
|
||||
| `X-Forwarded-Proto` | `proxy_set_header X-Forwarded-Proto $scheme;` | 자동 | issuer scheme (https 강제의 핵심) |
|
||||
| `X-Forwarded-Host` | `proxy_set_header X-Forwarded-Host $host;` | 자동 | issuer host (`KC_HOSTNAME_STRICT=true` 면 KC_HOSTNAME 이 우선) |
|
||||
| `X-Forwarded-Port` | ⚠️ nginx 초안 누락 — `proxy_set_header X-Forwarded-Port $server_port;` 추가 권장 | 자동 | issuer port |
|
||||
| `X-Forwarded-Prefix` | method A 채택 시만 (D3 은 method B 라 불요) | method A 채택 시만 | subpath (D3 은 relative-path 로 대체) |
|
||||
|
||||
### 2. Subpath 노출 — method B(`KC_HTTP_RELATIVE_PATH`) 채택 (D3)
|
||||
|
||||
> **Trace**: D3 / `keycloak-reverseproxy-official#KC-RP-C6`(subpath 노출 2방법: A=proxy `X-Forwarded-Prefix` 주입, B=Keycloak `http-relative-path`). 본 branch 는 **B** 채택 — SPA(`/`)+API(`/api/*`)+KC(`/keycloak/*`) 를 한 도메인에 묶는 부모 P3B 다이어그램과 정합.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: method A vs B 의 정확한 trade-off(admin console URL 변경, OIDC discovery 경로 영향)는 `KC-RP-C6` 이 "두 방법 존재" 만 증명하고 detail 은 does-not-prove. **B 채택 근거는 사용자 trade-off**: relative-path 는 Keycloak 이 스스로 모든 endpoint 를 `/keycloak/*` 로 발급하므로 proxy 가 prefix 를 매 요청 rewrite 할 필요가 없어 단순 — 단, admin console 도 `/keycloak/admin` 으로 이동하는 부작용(아래 표)을 감수.
|
||||
|
||||
| 항목 | method B (채택) | method A (대안) |
|
||||
|---|---|---|
|
||||
| Keycloak 설정 | `KC_HTTP_RELATIVE_PATH=/keycloak` | 무변경 (context path `/`) |
|
||||
| proxy 설정 | `location /keycloak/ { proxy_pass http://127.0.0.1:8080; }` (path 그대로 전달) | `X-Forwarded-Prefix: /keycloak` 주입 + `xforwarded` |
|
||||
| admin console URL | `/keycloak/admin` 으로 **이동** (함정 — §엣지) | `/admin` 유지 |
|
||||
| OIDC discovery | `/keycloak/realms/{realm}/.well-known/openid-configuration` | 동일(prefix 는 forwarded) |
|
||||
| broker endpoint (Google) | `https://kc.example.com/keycloak/realms/{realm}/broker/google/endpoint` | 동일 — Google Console 등록 URL owner=[[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]] |
|
||||
|
||||
> ⚠️ **method B ↔ proxy 라우팅 정합 함정 (depth 감사 2026-07-18 F1, 초안 수정 완료)**: 과거 초안의 `handle_path /keycloak/*`는 prefix를 strip해 method B와 충돌할 수 있으므로 위 copyable 예시를 `handle /keycloak/*` + `reverse_proxy`로 교정했다. nginx `location /keycloak/ { proxy_pass http://127.0.0.1:8080; }`와 마찬가지로 `/keycloak` prefix를 origin까지 보존하는 것이 이 문서의 deploy invariant다. 실제 Caddy route와 discovery 200 여부는 vendor raw 미보존 때문에 §Claims To Verify에서 확인한다.
|
||||
|
||||
### 3. `KC_HTTP_ENABLED=true` + hostname full-URL 상호작용 (D6 부분 · D7)
|
||||
|
||||
> **Trace**: D6 / `keycloak-reverseproxy-official#KC-RP-C4`(TLS edge termination 시 `http-enabled` 필수). D7 / `keycloak-hostname-configuration#KC-HOST-C4`(backchannel-dynamic=true 시 full URL 요구). proxy 가 HTTPS 를 종단하고 Keycloak `:8080` 에 **HTTP** forward 하는 것이 전제.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: `KC_HOSTNAME=https://kc.example.com` 의 **`https://` prefix 강제 여부** — `KC-HOST-C4` 는 `backchannel-dynamic=true` 조건에서만 full URL 을 요구한다. 단일 EC2 는 `backchannel-dynamic=false` 이므로 hostname-only(`kc.example.com`)로 충분한지 vs scheme 을 붙여야 일부 endpoint 가 http 로 새지 않는지 **미확정**. trade-off: 사용자 메모는 "scheme 없으면 일부 endpoint 가 http 로 발급되는 사례 보고" 라 항상 `https://` 를 붙이는 보수적 선택 — vendor 직접 근거 없음(§Claims To Verify).
|
||||
|
||||
| 항목 | 명세 | 근거 |
|
||||
|---|---|---|
|
||||
| `KC_HTTP_ENABLED` | `true` — proxy 가 HTTPS 종단 후 Keycloak 은 HTTP 로 수신 | `KC-RP-C4` (edge termination 시 필수) |
|
||||
| `KC_HOSTNAME` scheme | `https://` prefix 포함 (보수적 — issuer/discovery 를 https 로 고정) | `KC-HOST-C4` (조건부) + UNSUPPORTED_IMPL |
|
||||
| `X-Forwarded-Proto` 와의 관계 | proxy 가 `X-Forwarded-Proto: https` 주입 → Keycloak 이 http 수신에도 issuer 를 https 로 발급 | `KC-RP-C2` (proto 파싱) |
|
||||
| TLS 종단 위치 | proxy(nginx/Caddy/Cloudflare edge) — **본 branch 미소유**, [[raw/branch-notes/feature-keycloak-https-termination-caddy-nginx]] 참조 | R3 위임 |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4(깊이 게이트) 캡처용. 정상 경로 외에 *구현 중 부딪힐* 실패/엣지/다른 계약 의존을 미리 열거. 본 branch 는 `documented-only` 라 대부분 "실 적용 시 예상 함정" 이나, 실패 지점을 미리 명명해 둔다.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **`KC_HTTP_ENABLED=true` 누락 → 부팅 실패** (D6): proxy 가 HTTPS 를 edge termination 하고 Keycloak 에 HTTP forward 하는데 `http-enabled` 가 꺼져 있으면 production mode 는 HTTPS 를 강제해 부팅이 실패(`KC-RP-C4` 가 "필수" 명시). 단 "부팅 실패" 자체의 정확한 동작은 does-not-prove → §Claims To Verify.
|
||||
- **`KC_HTTP_RELATIVE_PATH` 변경 → admin console URL 동반 이동** (D3): `/keycloak` 설정 시 admin console 이 `/admin` → `/keycloak/admin` 으로 이동. 기존 북마크/자동화 스크립트가 `/admin` 을 하드코딩하면 404. 기대 동작: 모든 관리 접근을 `/keycloak/admin` 으로 통일.
|
||||
- **Caddy X-Forwarded-* 헤더 셋 불일치** (D1/D4): Caddy `reverse_proxy` 가 자동 emit 하는 헤더가 Keycloak `xforwarded` 기대 5종과 다르면(예: `X-Forwarded-Port` 누락) issuer port 가 틀어질 수 있음. Caddy vendor doc 미보존이라 실측 전엔 확정 불가(D4 UNSUPPORTED).
|
||||
- **nginx 초안의 `X-Forwarded-Port`/`X-Forwarded-Prefix` 누락** (D1): §진행 중 메모의 nginx config 는 For/Proto/Host 3종만 명시 — 5종 중 2종 누락. Keycloak 이 기본값으로 추정하는지, issuer port 가 틀어지는지 미검증.
|
||||
- **`KC_HOSTNAME` scheme 누락 → 일부 endpoint http 발급** (D7): `kc.example.com`(scheme 없음)으로 설정 시 일부 endpoint 가 http 로 발급되는 사례 보고(사용자 메모) → 항상 `https://` prefix. vendor 직접 근거 없음.
|
||||
- **subpath + OIDC discovery 경로 변화** (D3): `/keycloak/` subpath 하에서 discovery 의 모든 endpoint URL 이 `/keycloak/realms/.../` prefix 를 가져야 함. 하나라도 prefix 없이 발급되면 SPA/RS 가 endpoint 를 못 찾음. `KC-RP-C6` does-not-prove "OIDC discovery 경로 영향".
|
||||
|
||||
- **다른 계약 의존** (대상 브랜치 + Decision ID 병기):
|
||||
- [[raw/branch-notes/feature-keycloak-header-spoofing-defense]] **D6** — ⚠️ **RESTATED_FOREIGN_DECISION**. `KC_PROXY_TRUSTED_ADDRESSES=127.0.0.1`(proxy-header spoofing 방어)의 owner 는 그 branch(source doc `keycloak-reverseproxy-official.md` Parent 표가 지정). 본 branch D6 은 이 값을 재진술 → §Audit A1. 본 branch 의 `KC_PROXY_HEADERS=xforwarded`(D1) 은 헤더 **파싱만** 켜므로(`KC-RP-C2` does-not-prove spoofing 방어) trusted-addresses 없이는 spoofing 에 취약 — 두 계약이 **짝**으로만 안전.
|
||||
- [[raw/branch-notes/feature-keycloak-https-termination-caddy-nginx]] — `KC_HTTP_ENABLED=true`(D6)의 전제인 **TLS edge termination** 이 그 branch 소유. Caddy vs nginx 선택(D4)도 그 branch 가 owner — 본 branch 는 delegate. 그 branch 가 TLS passthrough 로 바꾸면 본 branch D6 의 `http-enabled` 전제가 무너짐.
|
||||
- [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] — `KC_HOSTNAME` **값** 과 `iss` 검증의 owner. 본 branch D2/D7 은 그 값에 proxy-headers 가 어떻게 상호작용하는지(strict=true 하에서 issuer 결정 우선순위)만 다룬다. 그 branch 가 hostname 값을 바꾸면 broker endpoint URL(아래) 도 연동 변경.
|
||||
- [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]] — D3 의 `/keycloak/` subpath 가 broker endpoint URL(`https://kc.example.com/keycloak/realms/{realm}/broker/google/endpoint`)을 결정 → Google Console authorized redirect URI 에 **subpath 포함** 필수. subpath 를 빼고 등록하면 Google federation redirect 실패. redirect URI 등록 정책은 그 branch 소유.
|
||||
- [[raw/branch-notes/feature-keycloak-single-ec2-google-federation]] **(부모)** — 본 sub-sub 는 그 배포의 **proxy-header 계약**(nginx/Caddy → Keycloak 8080, KC_HOSTNAME/relative-path)을 채우는 역할. 부모 다이어그램의 단일 도메인 subpath 배치가 D3 의 전제.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 vendor doc 이 옵션의 존재와 형식을 증명해도 단일 EC2 + Cloudflare Tunnel 조합에서의 실제 동작은 별개. 다음은 P3A 또는 실 Keycloak 구동 시 실측 필요.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| `KC_HOSTNAME=https://kc.example.com` 의 scheme prefix 가 단일 EC2 (`hostname-backchannel-dynamic=false`) 에서도 강제 필요한지 | `KC-HOST-C4` 의 조건절 ("If set to true, hostname option needs to be specified as a full URL") 만 명시 — false 시의 형식 강제 미확인 | scheme 없이 `KC_HOSTNAME=kc.example.com` 으로 부팅 시도 후 issuer URL 의 scheme 확인 | `needs-confirmation` |
|
||||
| `KC_HTTP_RELATIVE_PATH=/keycloak` 변경 후 admin console URL 이 `/keycloak/admin` 으로 변경되는 동작 | `KC-RP-C6` does-not-prove "admin console URL 변경 함정" | `/admin` vs `/keycloak/admin` 양쪽 접근 후 응답 확인 | `planned` |
|
||||
| Cloudflare Tunnel origin 이 HTTP 인데 `KC_HTTP_ENABLED=true` 누락 시 Keycloak 부팅 실패 (production mode 는 HTTPS 강제 디폴트) | 본 사용자 메모의 추론 — vendor 공식이 부팅 실패 자체를 명시했는지 verbatim 미확인 | `KC_HTTP_ENABLED` 미설정 + edge HTTP 환경에서 부팅 시도 후 에러 메시지 캡처 | `needs-confirmation` |
|
||||
| `KC_PROXY_TRUSTED_ADDRESSES=127.0.0.1` 로 충분한지 (loopback 만 신뢰 ⇒ 같은 호스트 nginx 만 헤더 신뢰) | `KC-RP-C5` does-not-prove "화이트리스트 외 IP 의 정확한 동작" | 외부 IP 에서 `X-Forwarded-For` 위조 요청 후 Keycloak access log 의 client IP 확인 | `needs-confirmation` |
|
||||
| Caddy `reverse_proxy localhost:8080` 가 자동 설정하는 X-Forwarded-* 헤더 셋이 Keycloak `xforwarded` 파싱 기대치 (5종) 와 일치 | `D4` UNSUPPORTED — Caddy 공식 vendor doc raw 보존 부재 | Caddy 뒤에 echo 서버 띄워 X-Forwarded-* 헤더 명세 확인 후 Keycloak 파싱 동작과 비교 | `planned` |
|
||||
| (과거 초안 회귀 방지) 폐기된 `handle_path /keycloak/*`와 현행 `handle`+`reverse_proxy`가 method B에서 실제로 다른 결과를 내는지 | Caddy 공식 vendor doc raw 미보존(D4 UNSUPPORTED). copyable 설정은 이미 prefix 보존형으로 교정했지만 runtime 검증은 아직 없음 | 현행 `handle` config로 `/keycloak/realms/{realm}/.well-known/openid-configuration` 200과 endpoint prefix를 확인. 비교 실험이 필요할 때만 폐기된 `handle_path`를 별도 negative case로 실행 | `needs-confirmation` |
|
||||
| `/keycloak/` subpath + `KC_HTTP_RELATIVE_PATH` 조합에서 OIDC discovery (`.well-known/openid-configuration`) endpoint 경로 변화 | `KC-RP-C6` does-not-prove "OIDC discovery 경로 영향" | discovery endpoint 호출 후 모든 endpoint URL prefix 검증 (`/keycloak/realms/.../auth` 등) | `needs-confirmation` |
|
||||
| Google OAuth client redirect URI 가 `https://kc.example.com/keycloak/realms/{realm}/broker/google/endpoint` 와 exact match 일 때만 동작 | 본 사용자 메모의 추론 — Google 공식 vendor doc raw 보존 부재 | `/keycloak/` 없는 URL 로 Google client 등록 후 federation 시도 → redirect 실패 확인 | `planned` |
|
||||
| `KC_HOSTNAME_STRICT_BACKCHANNEL=false` 유지가 단일 EC2 + Cloudflare Tunnel 조합에서 server-to-server 호출에 문제 없는지 | 본 사용자 메모의 추론 — vendor 인용 부재 | Keycloak 가 IdP discovery / token 발급 시 internal vs public hostname 사용 여부 wireshark 로 추적 | `planned` |
|
||||
|
||||
## Audit & Findings (2026-07-18 `/branch-spec` 정합 감사)
|
||||
|
||||
> 본 § 는 채움 중 발견한 **결정으로 흡수되지 않은 정합 문제·위임 권고**만 보존 (CLAUDE.md §15.5 R3 — 이관/위임 history 는 별도 § 에). 자동 rewrite 안 함 — 타 branch 결정 영역은 *정합 권고만*.
|
||||
|
||||
| ID | 유형 | 발견 | 조치 |
|
||||
|---|---|---|---|
|
||||
| **A1** | `RESTATED_FOREIGN_DECISION` (**미해소 — `/sync` owner 확정 권고**) | 본 branch **D6** 이 `KC_PROXY_TRUSTED_ADDRESSES=127.0.0.1` 를 결정하는데, 같은 값을 [[raw/branch-notes/feature-keycloak-header-spoofing-defense]] **D6** 도 결정한다("Keycloak reverse proxy 환경에서 `KC_PROXY_TRUSTED_ADDRESSES` 화이트리스트로 proxy header 송신 IP 제한 — 단일 EC2 = `127.0.0.1`"). source doc `keycloak-reverseproxy-official.md` 의 Parent 표(L28)가 **header-spoofing-defense 를 "`KC_PROXY_TRUSTED_ADDRESSES` 화이트리스트 도입 근거"의 소유 branch 로 지정** → spoofing 방어 관심사의 owner 는 그 branch. 본 branch 는 proxy-header **파싱 모드**(D1)의 owner 이지 spoofing 방어의 owner 가 아님 | **자동 rewrite 안 함**(D6 은 사용자 작성 결정). **2026-07-18 조치**: D6 의 신규 `선택 조건` 셀 + §구현 가이드 §0/§3(R3) + §엣지·실패·의존에 "trusted-addresses = header-spoofing-defense D6 위임, 본 branch 는 `KC_HTTP_ENABLED` 만 소유" 를 명시해 *노트가 spoofing owner 를 자처하는 상태*를 제거. **잔여 사용자 결정**: `/sync` 로 "proxy trusted-addresses" owner 를 header-spoofing-defense 로 확정하고, 본 branch D6 을 그 결정의 *reference-only 소비*(값 재진술 제거)로 격하할지 판단 |
|
||||
| **A2** | `BACKREF_INTEGRITY` (해소됨 — 이번 세션 hook 알림 대응) | 이번 세션의 Decision Evidence Map 수정(선택 조건 열 추가)에 대해 consistency hook 이 본 노트 D1·D6 을 참조하는 문서 2건을 비차단 알림: [[raw/branch-notes/feature-keycloak-header-spoofing-defense]] L206 → D6, [[raw/branch-notes/feature-keycloak-https-termination-caddy-nginx]] L159·L232 → D1 | **전파 불요 확인**: D1·D6 의 `Decision`/`Supporting Claims`/`Evidence Strength`/`Open Risk` 셀은 **verbatim 보존**하고 신규 `선택 조건` 열만 추가 — 참조된 의미(D1=`xforwarded` 채택, D6=`KC_HTTP_ENABLED`/trusted-addresses)는 불변. 두 citing 문서의 요약은 낡지 않음 → 갱신 없음 |
|
||||
| **A3** | `OUT_OF_BRANCH_SCOPE` 정제 (조치 완료) | §진행 중 메모의 nginx/Caddy config 초안이 TLS termination(`listen 443 ssl`, cert)까지 포함 — HTTPS termination 은 [[raw/branch-notes/feature-keycloak-https-termination-caddy-nginx]] 소유(D4 도 그 branch 에 delegate) | §진행 중 메모의 초안은 **사용자 작성이라 보존**. §구현 가이드(R3 정제)는 TLS 라인을 남기지 않고 proxy 가 **emit 하는 헤더 계약**만 명세. Caddy vs nginx 선택은 그 branch 로 위임 명시 |
|
||||
| **A4** | `IMPL_UNDERSPECIFIED` (depth 감사 F1 — **해소**) | 과거 Caddy 초안 `handle_path /keycloak/*`(prefix strip)과 D3 method B(`KC_HTTP_RELATIVE_PATH`, prefix 보존 기대)의 충돌 가능성을 발견 | copyable Caddy snippet을 `handle /keycloak/*` + `reverse_proxy`로 수정해 prefix 보존 invariant와 일치시켰다. 폐기된 `handle_path`는 회귀 방지 역사/negative test에서만 언급하며 runtime 확인은 §Claims To Verify에 유지 |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 아직 없음 (문서 단계). 실 적용 시 예상되는 함정:
|
||||
- `KC_HOSTNAME`을 `kc.example.com`(scheme 없음)으로 적으면 일부 endpoint가 http로 발급되는 사례 보고 있음 → 항상 `https://` prefix 포함.
|
||||
- `KC_HTTP_RELATIVE_PATH` 변경 후 admin console URL도 함께 변경 → `/keycloak/admin`이 됨에 유의.
|
||||
- Cloudflare Tunnel origin이 HTTP인데 `KC_HTTP_ENABLED=true` 누락 시 Keycloak 부팅 실패 (production mode는 HTTPS 강제 디폴트).
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/keycloak-hostname-configuration]]
|
||||
- [[raw/official-docs/keycloak-reverseproxy-official]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
> 본 branch 는 leaf — 자식 자료 없음. errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 오류 기록
|
||||
|
||||
- (없음)
|
||||
|
||||
### 면접 준비
|
||||
|
||||
- (없음)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 본 sub-sub-branch는 **문서까지만**. 실 Keycloak / nginx / Caddy 구동은 P3A 완료 후 선택적 확장.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: 해당 없음 (문서 단계, P3B 전체 `documented-only`)
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목: 없음
|
||||
- `locally-verified` 항목: 없음
|
||||
- `prod-verified` 항목: 없음
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
- 본 sub-sub-branch 전체가 `documented-only`. 추후 `wiki/concepts/keycloak-deployment-patterns.md` 합성 시 "Keycloak behind reverse proxy 함정" 섹션으로 인용 후보.
|
||||
+399
@@ -0,0 +1,399 @@
|
||||
---
|
||||
title: branch / feature-keycloak-single-ec2-google-federation (P3B Single EC2 + Google federation)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-CHILD-D5D01846
|
||||
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-google-federation
|
||||
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: d7981aec13ea69dbc518a68fa2709ba3fa9a7f9ccc7a849a7b039926a9e82f3c
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-single-ec2-google-federation (P3B Single EC2 + Google federation)
|
||||
|
||||
> Layer: `raw/branch-notes/` — Keycloak 패턴 **P3B** 한정 sub-branch. **단일 EC2**(P3A 토폴로지) + **Google IdP brokering**. SPA / Spring Boot / Keycloak이 한 호스트에 동거하면서 Keycloak이 Google을 외부 IdP로 위임. SPA flow는 P3A와 동일 (Keycloak만 호출).
|
||||
> 본 sub-branch는 **문서 + 다이어그램까지만**. 실제 EC2 + Google client 등록 + cloudflared / ngrok 시도는 P3A 완료 후의 선택적 확장.
|
||||
> `status_label`: `in-progress`
|
||||
|
||||
<!-- 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와 Google federation을 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 -->
|
||||
## 목표
|
||||
|
||||
P3A(단일 EC2, no Google)에 Google federation을 더했을 때 토큰 흐름이 어떻게 바뀌는지, 그리고 **단일 EC2 + 외부 IdP 조합이 만들어내는 새로운 제약**이 무엇인지 명확히 한다.
|
||||
|
||||
핵심 제약 하나: **Google이 Keycloak callback URI에 도달해야 함**. Google → Keycloak 사이는 redirect 기반이라 사용자 브라우저를 거치지만, 그 redirect URI는 **Google Cloud Console에 사전 등록된 HTTPS public URL**이어야 한다 (localhost 외에는 HTTP/raw IP 불가). 즉 P3A에서는 `localhost:8080`만으로도 됐지만 P3B는 **public domain + HTTPS**가 강제.
|
||||
|
||||
면접에서 답해야 할 질문:
|
||||
1. P3A → P3B 추가 비용은? → public domain + TLS + ngrok/Cloudflare Tunnel 학습.
|
||||
2. SPA 코드는 바뀌는가? → 안 바뀜. Keycloak이 Google과 OIDC로 통신, SPA는 늘 Keycloak token만 받음.
|
||||
3. Keycloak이 발급하는 token의 issuer는? → 여전히 Keycloak (Google이 아님). audience도 SPA client.
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- P3A → P3B 차이만 (P3A 본문은 [[raw/branch-notes/feature-keycloak-single-ec2-no-google]]).
|
||||
- 단일 EC2 + 외부 IdP의 제약 (public hostname, HTTPS, Google Console redirect URI 등록).
|
||||
- public issuer·callback·reverse-proxy path가 서로 일치해야 한다는 배포 invariant와 [[raw/branch-notes/feature-keycloak-reverse-proxy-headers]] owner pointer.
|
||||
- Google OAuth client의 Admin UI 표시 callback exact-match invariant와 [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]] owner pointer.
|
||||
- public URL provider 선택(부모 D3)과 [[raw/branch-notes/feature-keycloak-public-domain-tunneling]] 운영 메커니즘 pointer.
|
||||
- public HTTPS invariant와 [[raw/branch-notes/feature-keycloak-https-termination-caddy-nginx]] owner pointer.
|
||||
- 토큰 교환 sequence (Keycloak ↔ Google brokering이 P3A flow에 삽입되는 위치).
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 실제 EC2 프로비저닝 / Google Cloud Console 등록 / cloudflared 데몬 구동 → 본 sub-branch 범위 밖.
|
||||
- Google 외 외부 IdP (GitHub / Auth0 / Cognito).
|
||||
- SAML brokering (OIDC만).
|
||||
- multi-realm / multi-tenant.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 본 sub-branch의 P3B (Single EC2 + Google federation) 채택 근거. 상세 대안 비교는 §외부 근거 / 대안 조사 참조.
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/google-oauth2-redirect-uri-validation-official]] | Google OAuth redirect URI 검증 규칙 — public domain 확보 필요 근거 |
|
||||
| [[raw/official-docs/keycloak-reverseproxy-official]] | Keycloak behind reverse proxy — proxy 헤더 설정 근거 |
|
||||
| [[raw/official-docs/keycloak-hostname-configuration]] | Keycloak hostname guide — proxy 환경 추가 설정 근거 |
|
||||
| [[raw/official-docs/keycloak-identity-brokering-overview-official]] | Identity Broker (P1B/P2B 공유) — Google IdP brokering 근거 |
|
||||
| [[raw/official-docs/ngrok-http-tunnel-official]] | ngrok HTTP tunnel — 임시 public URL 근거 |
|
||||
| [[raw/official-docs/cloudflare-tunnel-routing-official]] | Cloudflare Tunnel — ngrok 대안 (정적 도메인) 근거 |
|
||||
|
||||
## 컴포넌트 다이어그램
|
||||
|
||||
### 텍스트
|
||||
|
||||
```
|
||||
EC2 (public IP / 도메인 필요)
|
||||
├─ nginx or Caddy (port 80/443, TLS termination)
|
||||
├─ Spring Boot (port 8081, Resource Server)
|
||||
└─ Keycloak (port 8080, behind reverse proxy)
|
||||
|
||||
[1] Browser → EC2:443 → SPA load (HTML/JS, vanilla)
|
||||
[2] Browser → EC2:443/keycloak/realms/{realm}/protocol/openid-connect/auth
|
||||
→ Keycloak 로그인 화면 (사용자 "Google 로그인" 선택)
|
||||
[3] Keycloak → 302 redirect → Google OIDC authorize endpoint (외부)
|
||||
[4] Browser → Google 인증 UI → 사용자 동의
|
||||
[5] Google → 302 redirect → EC2:443/keycloak/realms/{realm}/broker/google/endpoint
|
||||
(=Keycloak broker endpoint, 반드시 public 접근 가능)
|
||||
[6] Keycloak ← (server-to-server) Google /token endpoint → Google ID token + access token
|
||||
[7] Keycloak이 Google user → Keycloak user 매핑 (first-login: 신규 생성)
|
||||
[8] Keycloak → 302 redirect → SPA callback (Keycloak code)
|
||||
[9] SPA → EC2:443/keycloak/realms/{realm}/protocol/openid-connect/token
|
||||
→ Keycloak access token + refresh token + ID token
|
||||
[10] Browser → EC2:443/api/* (Authorization: Bearer <keycloak-access-token>)
|
||||
→ Spring Boot → Keycloak JWKS (localhost 내부) → 검증 → 응답
|
||||
```
|
||||
|
||||
### Mermaid
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant B as Browser (SPA)
|
||||
participant N as nginx (EC2 :443)
|
||||
participant K as Keycloak (EC2 :8080)
|
||||
participant G as Google OIDC
|
||||
participant API as Spring Boot (EC2 :8081)
|
||||
|
||||
B->>N: GET / (SPA load)
|
||||
N-->>B: index.html
|
||||
B->>N: GET /keycloak/.../auth
|
||||
N->>K: proxy
|
||||
K-->>B: 로그인 화면 (Google 선택지 포함)
|
||||
B->>K: "Google 로그인" 선택
|
||||
K-->>B: 302 redirect to Google authorize
|
||||
B->>G: authorize (Google client_id)
|
||||
G-->>B: 사용자 인증 + 동의
|
||||
G-->>B: 302 redirect to https://kc.example.com/keycloak/.../broker/google/endpoint?code=...
|
||||
B->>N: GET /keycloak/.../broker/google/endpoint?code=...
|
||||
N->>K: proxy
|
||||
K->>G: POST /token (code + client_secret) [server-to-server]
|
||||
G-->>K: Google ID token + access token
|
||||
K->>K: Google user → Keycloak user 매핑
|
||||
K-->>B: 302 redirect to SPA callback (Keycloak code)
|
||||
B->>N: GET /keycloak/.../token (code exchange)
|
||||
N->>K: proxy
|
||||
K-->>B: Keycloak access/refresh/ID token
|
||||
B->>N: GET /api/orders (Bearer KC token)
|
||||
N->>API: proxy
|
||||
API->>K: JWKS fetch (localhost, 내부)
|
||||
K-->>API: JWKS
|
||||
API-->>B: 200 OK
|
||||
```
|
||||
|
||||
## 토큰 교환 sequence (P3A 대비 추가 부분만)
|
||||
|
||||
P3A의 단계는 [[raw/branch-notes/feature-keycloak-single-ec2-no-google]]. 본 sub-branch는 **Keycloak ↔ Google brokering이 어디 끼는지**만 명확히.
|
||||
|
||||
- P3A 단계 1 (SPA → Keycloak /auth)까지 동일.
|
||||
- P3A 단계 2 (사용자 로그인)에서 **사용자가 "Google" identity provider 선택** → 아래 brokering 분기 삽입:
|
||||
- **B-1**: Keycloak이 사용자 브라우저를 Google `/authorize`로 302 redirect (Google client_id, Keycloak이 redirect_uri로 자기 broker endpoint 전달).
|
||||
- **B-2**: 사용자가 Google에서 인증 → Google이 사용자 브라우저를 **Keycloak broker endpoint** (`/realms/{realm}/broker/google/endpoint?code=...`)로 302 redirect.
|
||||
- **B-3**: Keycloak이 server-to-server로 Google `/token`에 code → Google ID token + access token 교환.
|
||||
- **B-4**: Keycloak이 ID token claim(email 등)으로 Keycloak user를 lookup / first-login 시 신규 생성.
|
||||
- 이후 P3A 단계 3-5 (Keycloak이 SPA에 code 발급 → SPA가 token exchange → SPA가 backend 호출)는 동일.
|
||||
|
||||
핵심: **SPA가 받는 token은 Google token이 아니라 Keycloak token**. Google token은 Keycloak이 보관 (broker link 정보).
|
||||
|
||||
## 단일 EC2 + Google federation 추가 제약
|
||||
|
||||
P3A 대비 늘어나는 운영 요구사항. 이게 P3B의 학습 포인트.
|
||||
|
||||
### 1) 공개 도메인 필수
|
||||
|
||||
- Google Cloud Console "Authorized redirect URIs"에 등록할 URL은 **HTTPS + 도메인** 형식 (localhost / raw IP 불가, 단 localhost는 dev 한정 일부 허용).
|
||||
- 단일 EC2 학습 환경이라도 도메인 1개 + DNS A 레코드 → EC2 public IP 매핑 필요.
|
||||
- 근거: [[raw/official-docs/google-oauth2-redirect-uri-validation-official]].
|
||||
|
||||
### reverse-proxy 정합
|
||||
|
||||
- 배포 invariant: 브라우저가 보는 public issuer·OIDC discovery·broker callback path와 proxy가 origin에 전달하는 host/scheme/path가 일치해야 한다. 환경변수·header·subpath의 정확한 값과 method 선택은 [[raw/branch-notes/feature-keycloak-reverse-proxy-headers]]가 소유하며 본 문서는 재진술하지 않는다.
|
||||
|
||||
### 3) 개발 환경 — stable public callback
|
||||
|
||||
- 부모 D3의 선택: 반복 가능한 Google callback은 **Cloudflare named tunnel + Cloudflare가 관리하는 custom domain**을 사용한다. `trycloudflare.com` quick tunnel과 ngrok random URL은 dev-only fallback이며 URL이 바뀔 때 Google Console callback도 함께 갱신한다. 명령·DNS·ingress 절차는 [[raw/branch-notes/feature-keycloak-public-domain-tunneling]]가 소유한다.
|
||||
|
||||
### 4) TLS termination
|
||||
|
||||
- 배포 invariant: Google에 등록하는 public callback은 HTTPS여야 하고 선택한 TLS termination 경로가 public scheme을 끝까지 보존해야 한다. Caddy/nginx/Cloudflare의 선택과 설정 상세는 [[raw/branch-notes/feature-keycloak-https-termination-caddy-nginx]]가 소유한다.
|
||||
|
||||
### 5) Google Cloud Console 등록
|
||||
|
||||
- 배포 invariant: Keycloak Admin UI가 표시한 broker callback 값을 Google Cloud Console에 그대로 등록하고 실제 요청값과 exact match시킨다. URL 조립·갱신·검증 절차는 [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]]가 소유하며 본 문서는 endpoint 문자열을 재구성하지 않는다.
|
||||
|
||||
## 장점 / 단점 vs P3A
|
||||
|
||||
### 장점
|
||||
|
||||
- **사용자 Google 로그인 가능**: Keycloak user store 외에 social login 1개 추가.
|
||||
- **P3A 학습 + Google federation 학습 동시**: 단일 EC2의 단순함 + OIDC brokering의 핵심을 한 번에.
|
||||
- **SPA 코드 영향 0**: SPA는 Keycloak만 호출. Identity provider 추가/제거는 Keycloak 측 설정.
|
||||
- **token issuer가 Keycloak으로 통일**: backend는 Google JWT를 직접 검증할 필요 없음 (Keycloak이 broker).
|
||||
|
||||
### 단점
|
||||
|
||||
- **public 도메인 + HTTPS 요구**: 학습 friction +1 (P3A는 localhost로 끝남).
|
||||
- **random URL 갱신 friction**: ngrok/quick tunnel URL이 바뀌면 Google Console callback도 갱신해야 함. 반복 학습은 D3의 Cloudflare named tunnel + managed custom domain 사용.
|
||||
- **운영 surface 증가**: Google client_secret 관리, Keycloak hostname 잘못 설정 시 invalid_redirect_uri 디버깅 비용.
|
||||
- **사용자 매핑 정책 결정**: Google email → 기존 Keycloak user 자동 link 여부 (first-login flow 설정).
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [x] P3A→P3B brokering 분기 삽입 위치 sequence 명세 (§토큰 교환 sequence) — 등급: `documented-only`
|
||||
- [x] 단일 EC2 + Google federation 추가 제약 5종 정리 (§단일 EC2 추가 제약) — 등급: `documented-only`
|
||||
- [x] 컴포넌트/시퀀스 다이어그램 (§컴포넌트 다이어그램) — 등급: `documented-only`
|
||||
- [x] 대안 5종 비교 조사 (§외부 근거 / 대안 조사) — 등급: `documented-only`
|
||||
- [ ] 실 EC2 프로비저닝 + Google client 등록 + cloudflared 구동 — 등급: `planned` (본 sub-branch **범위 밖**, P3A 완료 후 선택 확장)
|
||||
- [ ] §Claims To Verify 6종 실 기동 검증 — 등급: `planned` (실 배포 시점에만 가능)
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- 본 sub-branch 는 **documented-only** (D1). 실 배포(EC2 / Google client / cloudflared)는 P3A 완료 후 선택 확장 — 여기서는 config recipe + sequence + 제약만 명세한다.
|
||||
- P3A([[raw/branch-notes/feature-keycloak-single-ec2-no-google]]) 토폴로지에 Google brokering 분기만 삽입 — 새로 생기는 요구는 "public 접근 가능한 callback URL 도달성" 하나뿐(§목표, §토큰 교환 sequence).
|
||||
- §구현 가이드는 child owner pointer와 deploy invariant만 제공한다. 각 child의 config/명령 및 결합 chain은 실 기동 검증 전까지 `actually-implemented`로 승격하지 않는다.
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- **2026-05-25**: 본 패턴은 **문서 + 다이어그램까지만**. 실 구현(EC2 프로비저닝, Google client 등록, cloudflared 구동)은 진행하지 않음. 이유: root branch가 "P3A 한정 구현"으로 결정 → P3B는 P3A 완료 후 선택적 확장.
|
||||
- **2026-05-25**: P3B의 핵심 학습 포인트를 "Google이 Keycloak callback URI에 도달해야 한다는 제약" 단 한 줄로 압축. 나머지(hostname, proxy headers, ngrok 등)는 그 제약의 파생.
|
||||
- **2026-07-18 (D3 owner clarification)**: stable Google callback의 기본 경로는 **Cloudflare named tunnel + Cloudflare-managed custom domain**이다. `trycloudflare.com` quick tunnel과 ngrok random URL은 dev-only fallback이며 URL 회전 시 Google Console callback 갱신이 필요하다. provider 우선순위는 본 부모 D3가 소유하고, 운영 명령·DNS 메커니즘은 child가 소유한다.
|
||||
- **2026-05-25**: Keycloak ↔ Google brokering 흐름은 P1B / P2B와 **OIDC sequence 동일** — 차이는 "어디에 Keycloak이 떠 있나"뿐. 본 sub-branch는 P3A 토폴로지 + brokering 분기 삽입 위치만 명시.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지. P3B 는 문서/다이어그램 단계라 일부 결정은 우선순위/scope 기반 → `UNSUPPORTED_DECISION`.
|
||||
|
||||
| Decision ID | Decision | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|
|
||||
| D1 | 본 패턴은 문서 + 다이어그램까지만 (실 EC2/Google client/cloudflared 구동 안 함) | `UNSUPPORTED_DECISION` — 학습 scope 결정으로 공식 근거 대상 아님 | (project scope 결정) | 실 구현 없이 문서만으로 면접 답변 시 "직접 해본 것"으로 오해 금지 — `documented-only` 등급 명시 필수 |
|
||||
| D2 | P3B 의 핵심 학습 포인트를 "Google 이 Keycloak callback URI 에 도달해야 한다" 한 줄로 압축 | `raw/official-docs/google-oauth2-redirect-uri-validation-official.md#GOOGLE-REDIR-C1`, `raw/official-docs/google-oauth2-redirect-uri-validation-official.md#GOOGLE-REDIR-C2`, `raw/official-docs/google-oauth2-redirect-uri-validation-official.md#GOOGLE-REDIR-C3` | `official-vendor-doc` | "한 줄로 압축" 자체는 학습 framing — 실 구현 시 hostname/proxy headers 가 추가 결정점으로 부각될 수 있음 |
|
||||
| D3 | **public URL provider 선택 owner** — stable Google callback은 Cloudflare named tunnel + managed custom domain, random quick tunnel/ngrok는 dev-only fallback | `raw/official-docs/cloudflare-tunnel-routing-official.md#CLOUDFLARE-TUNNEL-C1`, `raw/official-docs/cloudflare-tunnel-routing-official.md#CLOUDFLARE-TUNNEL-C2`, `raw/official-docs/cloudflare-tunnel-routing-official.md#CLOUDFLARE-TUNNEL-C4`, `raw/official-docs/ngrok-http-tunnel-official.md#NGROK-C1`, `raw/official-docs/ngrok-http-tunnel-official.md#NGROK-C3`, `raw/official-docs/ngrok-http-tunnel-official.md#NGROK-C4`, `raw/official-docs/google-oauth2-redirect-uri-validation-official.md#GOOGLE-REDIR-C3`, `raw/official-docs/google-oauth2-redirect-uri-validation-official.md#GOOGLE-REDIR-C5` | `official-vendor-doc + official-vendor-doc + official-vendor-doc` | managed custom domain의 Google 등록과 end-to-end callback은 미검증. child [[raw/branch-notes/feature-keycloak-public-domain-tunneling]]는 이 선택을 소비해 운영 profile만 소유 |
|
||||
| D4 | **reverse-proxy deploy invariant** — public issuer·discovery·broker callback의 host/scheme/path가 proxy가 전달하는 값과 일치해야 함. [[raw/branch-notes/feature-keycloak-reverse-proxy-headers]] D1 — proxy-header parsing invariant. [[raw/branch-notes/feature-keycloak-reverse-proxy-headers]] D3 — public path assembly invariant | `raw/official-docs/keycloak-hostname-configuration.md#KC-HOST-C2`, `raw/official-docs/keycloak-hostname-configuration.md#KC-HOST-C5`, `raw/official-docs/keycloak-reverseproxy-official.md#KC-RP-C2`, `raw/official-docs/keycloak-reverseproxy-official.md#KC-RP-C3`, `raw/official-docs/keycloak-reverseproxy-official.md#KC-RP-C4`, `raw/official-docs/keycloak-reverseproxy-official.md#KC-RP-C5`, `raw/official-docs/keycloak-reverseproxy-official.md#KC-RP-C6` | `official-vendor-doc + delegated detail` | target proxy chain에서 discovery issuer와 callback을 실측하고 child decision과 대조 필요 |
|
||||
| D5 | Google `email_verified=true` + `hd` 정책 검사 + First Broker Login Flow 로 사용자 매핑 | `raw/official-docs/keycloak-identity-brokering-overview-official.md#KC-IDP-BROKER-C1`, `raw/official-docs/keycloak-first-broker-login-flow.md#KC-FBL-C1`, `raw/official-docs/google-oidc-discovery-spec.md#GOOGLE-OIDC-C5`, `raw/official-docs/google-oidc-discovery-spec.md#GOOGLE-OIDC-C7` | `official-vendor-doc` | `KC-FBL-C2`/`C3`/`C4` 는 모두 `needs-confirmation` 상태 (2026-05-25 quote, 재검증 보류). 실 동작 검증 시 first-broker-login authenticator UI 직접 확인 필요 |
|
||||
| D6 | **redirect deploy invariant** — Keycloak Admin UI가 표시한 broker callback을 Google Cloud Console에 그대로 등록하고 실제 요청값과 exact match시킴. URL 조립·갱신 정책은 [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]] 소유 | `raw/official-docs/keycloak-identity-brokering-overview-official.md#KC-IDP-BROKER-C2`, `raw/official-docs/google-oauth2-redirect-uri-validation-official.md#GOOGLE-REDIR-C3` | `needs-confirmation + official-vendor-doc + delegated detail` | Admin UI 표시값과 실제 요청값의 target-version 일치 여부를 실 로그인으로 확인 필요 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 본 sub-branch 는 **documented-only** (D1) — 아래는 *실 배포 시 되묻지 않을 config recipe* 의 사전 명세이며, 어느 항목도 아직 기동 검증되지 않았다(각 등급 열 = `planned`/`needs-confirmation`). 값의 상세 서술 owner 는 §단일 EC2 + Google federation 추가 제약 이고, 여기서는 **Trace(D-ID + Claim ID) + 임의결정 라벨**만 정리한다(재진술 금지).
|
||||
|
||||
### 1. Reverse-proxy / hostname integration contract (Reference-Only)
|
||||
|
||||
> **Trace**: D4 + `KC-HOST-C2/C5`, `KC-RP-C2..C6`.
|
||||
|
||||
- 본 부모가 유지하는 것은 **public issuer·discovery·broker callback의 host/scheme/path가 proxy 전달값과 일치한다**는 deploy invariant 한 줄이다.
|
||||
- [[raw/branch-notes/feature-keycloak-reverse-proxy-headers]] D1 — proxy-header parsing invariant. [[raw/branch-notes/feature-keycloak-reverse-proxy-headers]] D3 — public path assembly invariant.
|
||||
- [[raw/branch-notes/feature-keycloak-header-spoofing-defense]] D6 — trusted proxy boundary owner. [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] D6 — hostname/issuer bridge profile owner. TLS 종단은 [[raw/branch-notes/feature-keycloak-https-termination-caddy-nginx]]가 소유한다. 본 문서에서 값을 복제하지 않는다.
|
||||
|
||||
### 2. Public callback URL 선택 contract (Reference-Only)
|
||||
|
||||
> **Trace**: D3 + `CLOUDFLARE-TUNNEL-C1/C2/C4`, `NGROK-C1/C3/C4`, `GOOGLE-REDIR-C3/C5`.
|
||||
|
||||
- stable callback은 Cloudflare named tunnel + managed custom domain, random quick tunnel/ngrok는 dev-only fallback이라는 **선택**만 본 부모 D3가 소유한다.
|
||||
- tunnel 생성·DNS·ingress·실행 명령과 fallback 운영 절차는 [[raw/branch-notes/feature-keycloak-public-domain-tunneling]] D1/D2가 소유한다.
|
||||
|
||||
### 3. Google callback 등록 contract (Reference-Only)
|
||||
|
||||
> **Trace**: D6 + `KC-IDP-BROKER-C2`, `GOOGLE-REDIR-C3`.
|
||||
|
||||
- Keycloak Admin UI가 표시한 callback 문자열을 Google Cloud Console에 그대로 등록하고 실제 요청값과 exact match시킨다.
|
||||
- client type, endpoint URL 조립, trailing slash/case, URL 회전 시 갱신 절차는 [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]] D1/D8이 소유한다. 본 부모는 특정 endpoint 문자열을 copyable 값으로 제공하지 않는다.
|
||||
|
||||
### 4. First Broker Login 사용자 매핑 (consume-only — 정책 owner 는 sibling branch)
|
||||
|
||||
> **Trace**: 본 sub-branch(P3B 토폴로지)는 first-broker-login flow 를 **consume** 만 한다 — 매칭키·소유증명·auto-link 정책 자체는 **다른 branch 소유**(아래 위임)이며 여기서 재정의하지 않는다. 소비 지점 근거: `keycloak-first-broker-login-flow#KC-FBL-C1` (First login flow 존재) · `google-oidc-discovery-spec#GOOGLE-OIDC-C6` (sub = unique primary key) · `#GOOGLE-OIDC-C7` (hd = Workspace 도메인) · `keycloak-identity-brokering-overview-official#KC-IDP-BROKER-C1`.
|
||||
>
|
||||
> - **OUT_OF_BRANCH_SCOPE (위임, 재정의 금지)**: (a) linking key `email` vs `sub` → [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] `D1` (sub-only 결정, core owned) 소유. (b) 기존 local 계정 link 시 password 재인증 / Confirm Link Existing Account authenticator → [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] `D2` + [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] `D2` 소유. (c) `email_verified=false` silent auto-link 차단 → [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] `D4` (AutoLink DISABLED = core) 소유.
|
||||
|
||||
- **P3B 범위 consume 지점**: Google ID token claim(`email`, `email_verified`, `hd`, `sub`)이 single-EC2 배치의 Keycloak first-broker-login flow 로 유입 → user lookup / first-login 신규 생성. 매칭·link 정책의 종결은 위 owner 브랜치(§엣지·실패·의존 "다른 계약 의존"에도 링크). 본 노트는 그 정책이 *어느 배치에서든 동일하게* 적용됨을 전제로 P3A 토폴로지 위에서 flow 를 실행할 뿐.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4 캡처용. 정상 sequence(§컴포넌트 다이어그램) 외에 실 배포 시 부딪힐 실패/엣지 + 다른 branch 계약 의존. 본 sub-branch 는 documented-only 이므로 아래는 *실 기동 시 예상되는* 경로다(§Claims To Verify 가 검증 방법 owner).
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- `redirect_uri_mismatch`: Google Console 등록 URI와 Admin UI 표시 callback이 다르면 로그인 거부. 기대 동작과 갱신 절차는 [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]] D1/D8을 따른다.
|
||||
- reverse-proxy public context 유실: proxy chain 어느 홉에서든 public host/scheme/path 계약이 깨지면 discovery·issuer·callback 정합이 무너진다. 정확한 header/env 검증은 [[raw/branch-notes/feature-keycloak-reverse-proxy-headers]]가 소유한다.
|
||||
- random URL 회전: dev-only fallback URL이 바뀌면 등록 callback이 stale해진다. 반복 사용은 D3의 named tunnel + managed custom domain으로 전환한다.
|
||||
- proxy-header spoofing: trusted proxy 경계가 잘못되면 외부 입력이 public URL 계산에 개입할 수 있다. 구체 방어값과 검증은 [[raw/branch-notes/feature-keycloak-header-spoofing-defense]] D6이 소유한다.
|
||||
- first-broker-login 중복 email: 같은 email 의 local user 선점 시 무단 link 위험 → "Confirm Link Existing Account" authenticator 필요(`KC-FBL-C2`, §Claims To Verify #6).
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-keycloak-single-ec2-no-google]] (P3A) 의 **단일 EC2 reverse-proxy 토폴로지** 에 의존 — 본 브랜치는 그 base 에 brokering 분기만 삽입(§목표). P3A 의 nginx/port 배치 결정이 바뀌면 본 브랜치 config(§구현 가이드 1) 영향.
|
||||
- [[raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation]] (P1B) · [[raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation]] (P2B) 와 **동일 OIDC brokering sequence** — 차이는 배치뿐(§결정 사항 4). brokering flow 결정이 바뀌면 세 브랜치 공동 갱신.
|
||||
- **account-linking / first-broker-login 정책 의존** (§구현 가이드 4 consume-only): [[raw/branch-notes/feature-keycloak-account-linking-sub-vs-email]] `D1`(sub-only 매칭키)·`D2`(기존계정 link 재인증) + [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] `D2`(Confirm Link)·`D4`(email_verified auto-link 차단) 가 소유. 본 브랜치는 그 정책을 배치 무관하게 consume — 정책이 바뀌면 본 노트 §구현 가이드 4 의 consume 서술도 갱신.
|
||||
- sub-sub-branch 관심사 위임: [[raw/branch-notes/feature-keycloak-public-domain-tunneling]](tunnel 상세) · [[raw/branch-notes/feature-keycloak-reverse-proxy-headers]](proxy header 상세) · [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]](redirect URI 정책) · [[raw/branch-notes/feature-keycloak-https-termination-caddy-nginx]](TLS termination) 가 각 관심사 detail owner.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서는 P3B 의 각 요소를 보장하지만, 전체 chain (Cloudflare Tunnel → nginx → Keycloak → Google) 의 결합 동작은 실 구현 시점에서만 검증 가능.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| Cloudflare named tunnel + managed custom domain이 Google callback 등록과 실제 brokering에 통과 | 공식 자료는 각 구성요소를 다루지만 결합 chain은 보장하지 않음 | child [[raw/branch-notes/feature-keycloak-public-domain-tunneling]]의 acceptance 절차로 managed hostname 등록·실 login 확인 | `needs-confirmation` |
|
||||
| proxy chain이 public host/scheme/path를 보존해 discovery issuer와 callback이 동일 public context를 사용하는지 | Keycloak의 parsing ability와 전체 chain 결합 동작은 별개 | [[raw/branch-notes/feature-keycloak-reverse-proxy-headers]]의 discovery·header acceptance 결과를 본 D4 invariant와 대조 | `planned` |
|
||||
| Keycloak Admin UI 표시 callback과 Google Cloud Console 등록값이 exact match해 실제 login이 성공하는지 | `GOOGLE-REDIR-C3` 정책은 확보했지만 target-version UI 값과 실제 요청 결합은 미검증 | [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]]의 exact-match test 수행 | `needs-confirmation` |
|
||||
| child owner의 trusted-proxy 설정이 외부 spoofing을 차단하는지 | 옵션 존재와 실제 drop/ignore/log 동작은 별개 | [[raw/branch-notes/feature-keycloak-header-spoofing-defense]] D6의 negative test 결과 참조 | `planned` |
|
||||
| Keycloak first-broker-login flow 가 Account Linking 시 "비밀번호 확인 후 link" 정책으로 실제 작동 | `KC-FBL-C2` 등이 `needs-confirmation` 등급 — 정책 UI 토글 위치/동작 불확실 | Admin UI → Authentication → First Broker Login → flow copy + Confirm Link Existing Account authenticator 추가 → 같은 email 의 local user 사전 생성 후 Google 로그인 시도 | `needs-confirmation` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 아직 없음 (문서 단계).
|
||||
|
||||
## 묶음 (자식 sub-sub-branches)
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/cloudflare-tunnel-routing-official]]
|
||||
- [[raw/official-docs/google-oauth2-redirect-uri-validation-official]]
|
||||
- [[raw/official-docs/keycloak-hostname-configuration]]
|
||||
- [[raw/official-docs/keycloak-identity-brokering-overview-official]]
|
||||
- [[raw/official-docs/keycloak-reverseproxy-official]]
|
||||
- [[raw/official-docs/ngrok-http-tunnel-official]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
- [[raw/branch-notes/feature-keycloak-public-domain-tunneling]]
|
||||
- [[raw/branch-notes/feature-keycloak-reverse-proxy-headers]]
|
||||
- [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]]
|
||||
- [[raw/branch-notes/feature-keycloak-https-termination-caddy-nginx]]
|
||||
|
||||
> Sources는 하단 "외부 근거" 섹션. Errors/Interview/Lectures/Blog 는 현재 없음.
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 관련 sub-branch
|
||||
|
||||
- [[raw/branch-notes/feature-keycloak-patterns]] (root)
|
||||
- [[raw/branch-notes/feature-keycloak-single-ec2-no-google]] — P3A Single EC2 (no Google) **← P3B의 베이스 토폴로지, vanilla JS 구현 대상**
|
||||
- [[raw/branch-notes/feature-keycloak-edge-forwardauth-google-federation]] — P1B Edge + Google federation (다른 배치, 동일 brokering 흐름)
|
||||
- [[raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation]] — P2B Internal + Google federation (다른 배치, 동일 brokering 흐름)
|
||||
|
||||
## 외부 근거 / 대안 조사 (2026-05-25 — P3B Single EC2 + Google IdP Brokering)
|
||||
|
||||
본 sub-branch의 **단일 EC2 + Google IdP Brokering** 채택에 대한 외부 source. P3A에 federation 추가 시 발생하는 **public 도메인 + HTTPS 요구사항** 중심.
|
||||
|
||||
- **채택 결정 (Single EC2 + Public Domain + Keycloak Reverse Proxy 설정 + Google IdP)**:
|
||||
- [[raw/official-docs/google-oauth2-redirect-uri-validation-official]] — Google OAuth client redirect URI 검증 규칙 (localhost test-only, prod HTTPS 필수)
|
||||
- [[raw/official-docs/keycloak-reverseproxy-official]] — Keycloak behind reverse proxy (KC_PROXY_HEADERS, KC_HTTP_RELATIVE_PATH)
|
||||
- [[raw/official-docs/keycloak-hostname-configuration]] — Keycloak hostname guide (proxy 환경 추가 설정)
|
||||
- [[raw/official-docs/keycloak-identity-brokering-overview-official]] — Identity Broker (P1B/P2B 공유 source)
|
||||
- [[raw/official-docs/ngrok-http-tunnel-official]] — ngrok HTTP tunnel (개발 환경 임시 public URL)
|
||||
- [[raw/official-docs/cloudflare-tunnel-routing-official]] — Cloudflare Tunnel (ngrok 대안, 정적 도메인)
|
||||
- **검토한 대안**:
|
||||
- **대안 1: P3A 유지 (no Google federation)** — Google 학습을 별도 sub-project로. 장: 학습 friction 최소 / 단: federation 학습 누락. 비교 sub-branch: [[raw/branch-notes/feature-keycloak-single-ec2-no-google]].
|
||||
- **대안 2: localhost-only + Google Workspace SAML** — Workspace SAML은 일부 환경에서 localhost 허용. 그러나 일반 Google 계정은 OIDC만 + localhost 제한.
|
||||
- **대안 3: AWS EC2 public IP + Route53 도메인 + ACM cert** — production-like. 장: HTTPS termination 학습 / 단: AWS 비용 + cert provisioning 시간.
|
||||
- **대안 4: K8s + cert-manager + Let's Encrypt (P2B 진화)** — 분리 배치 + 자동 cert. 장: prod-like / 단: P3 목적(단일 host 학습)과 어긋남.
|
||||
- **대안 5: Cognito + Google federation (Keycloak 제거)** — AWS managed. 본 학습 목적에 부적합.
|
||||
- **비교 핵심**: P3A 대비 추가되는 핵심 운영 요구는 **public HTTPS callback URL**이다. 반복 가능한 학습 환경은 D3의 Cloudflare named tunnel + managed custom domain을 사용하고, random quick tunnel/ngrok는 dev-only fallback으로 취급한다. public issuer·proxy context는 [[raw/branch-notes/feature-keycloak-reverse-proxy-headers]], TLS는 [[raw/branch-notes/feature-keycloak-https-termination-caddy-nginx]], callback exact-match는 [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]]의 결정과 검증을 소비한다.
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 본 sub-branch는 **문서까지만**. wiki 추출은 root branch의 6 패턴 비교 매트릭스 시점에 일괄 처리.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: 해당 없음 (문서 단계, P3B 실 구현 안 함).
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목: 없음
|
||||
- `locally-verified` 항목: 없음
|
||||
- `prod-verified` 항목: 없음
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
- 본 sub-branch 전체가 `documented-only` 등급. 추후 root branch의 6 패턴 비교 매트릭스에 인용되는 형태로만 활용. `wiki/projects/` 직접 승급 없음.
|
||||
+391
@@ -0,0 +1,391 @@
|
||||
---
|
||||
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` (랜덤 43–128 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/로 승급.
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
---
|
||||
title: branch / feature-keycloak-spa-token-storage-tradeoff (Token 저장 위치 trade-off — localStorage / sessionStorage / memory / httpOnly cookie)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-PATTERNS-OVERVIEW-006
|
||||
kind: project-work-item
|
||||
project: keycloak-patterns-overview
|
||||
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-006
|
||||
inherits: [DEC-KEYCLOAK-PATTERNS-OVERVIEW-SPA-CLIENT-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-KEYCLOAK-PATTERNS-OVERVIEW-003]
|
||||
contract_packet: 1
|
||||
branch: feature-keycloak-spa-token-storage-tradeoff
|
||||
parent_branch:
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, p2a, token-storage, xss, csrf, spa, owasp]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 92acc553e2b9cf25e7fb7a8d9030574d20891bef35e5e652038e480f067be1c3
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-spa-token-storage-tradeoff — Token 저장 위치 trade-off
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/project-notes/keycloak-patterns-overview]]의 `WI-KEYCLOAK-PATTERNS-OVERVIEW-006` 직접 branch.
|
||||
> **목적**: access_token / refresh_token을 SPA에서 어디에 저장할지 결정하기 위한 4 저장소(localStorage / sessionStorage / memory / httpOnly cookie)의 XSS·CSRF 노출 trade-off를 표로 정리.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/project-notes/keycloak-patterns-overview]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: 저장 위치별 browser token read/XSS surface가 재현되고 선택이 기록된다
|
||||
|
||||
<!-- 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를 사용한다 | public SPA가 보유하는 token의 저장 위치와 XSS surface 비교에 적용한다 | [[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 -->
|
||||
## 목표
|
||||
|
||||
OWASP HTML5 Storage cheat sheet: *"Do not store sensitive data in Web Storage."* — localStorage / sessionStorage는 동일 origin의 모든 JS가 접근 가능 → XSS 1회 발생 시 토큰 즉시 탈취. 반면 httpOnly cookie는 JS 접근 불가지만 CSRF surface가 생긴다. 두 surface 중 **무엇을 선택해 무엇을 방어할지** 의식적으로 결정해야 한다.
|
||||
|
||||
핵심 질문:
|
||||
|
||||
- 4 저장소(localStorage / sessionStorage / memory / httpOnly cookie)의 XSS 노출과 CSRF 노출 비교?
|
||||
- refresh_token은 왜 access_token보다 더 엄격히 보호해야 하는가? (긴 TTL × 새 access_token 발급 권한)
|
||||
- OAuth 2.1 draft가 refresh_token 저장에 대해 권고하는 것은?
|
||||
- SPA reload 시 silent refresh / refresh_token cookie 패턴의 장단점?
|
||||
- Silent renew(iframe + `prompt=none`)는 왜 3rd-party cookie 제약으로 점점 어려워지는가?
|
||||
|
||||
본 sub-sub-branch는 **저장소별 비교표 + 권장 조합 + reload UX 고려**를 정리한다.
|
||||
|
||||
- 이슈: (학습 노트, 이슈 없음)
|
||||
- PR: (구현 없음)
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- 4 저장소(localStorage / sessionStorage / memory / httpOnly cookie)의 XSS·CSRF 노출 비교표 (문서화, `documented-only`)
|
||||
- pure SPA의 access_token / refresh_token **memory-only baseline**과 reload 재인증 결정
|
||||
- SPA reload 시 access_token 재획득 흐름(silent refresh / refresh_token grant / 재로그인) 옵션 비교
|
||||
- TMB/BFF variant를 별도 채택할 때 필요한 cookie/CSRF 계약의 경계 명시
|
||||
- PKCE `code_verifier` 저장 위치 결정 (D6)
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- **실제 SPA 구현** — vanilla JS SPA 의 token 메모리 보관/`/refresh` 호출 구현은 [[raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce]]
|
||||
- **BFF 백엔드 구현** — 본 branch 는 SPA Direct 전제. BFF vs SPA Direct 결정 자체는 [[raw/branch-notes/feature-keycloak-bff-vs-spa-direct]]
|
||||
- **refresh_token rotation / revocation 메커니즘 상세** — [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] (본 branch 는 "rotation 에 의존"만 결정, 메커니즘은 consume)
|
||||
- **CSRF token 발급/검증의 backend 실 구현** — pure SPA baseline에는 cookie credential이 없어 부과하지 않는다. HttpOnly refresh cookie를 쓰는 TMB/BFF variant를 채택하면 endpoint·cookie lifecycle·CSRF negative test를 소유하는 별도 계약을 먼저 지정해야 한다(`OWNER_REQUIRED`; audience-validator로 위임하지 않음)
|
||||
- **Keycloak realm/client 설정 상세** — [[raw/branch-notes/feature-keycloak-realm-client-export]]
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
- [[raw/official-docs/owasp-html5-storage-xss-spa]] — OWASP HTML5 Storage cheat sheet + XSS in SPA
|
||||
- [[raw/official-docs/oauth-v2-1-draft-ietf]] — OAuth 2.1 (refresh_token rotation / sender-constrained)
|
||||
- [[raw/company-tech-blogs/curity-bff-pattern-spa]] — Curity: 토큰을 브라우저에서 분리하라는 권고
|
||||
- [[raw/official-docs/third-party-cookie-blocking-safari-webkit-official]] — WebKit: Safari 13.1 / iOS 13.4 (2020-03-24) 이후 third-party cookie 기본 차단 → D3 (silent renew `prompt=none` 실패) 근거
|
||||
- [[raw/official-docs/silent-check-sso-third-party-cookies-keycloak-official]] — Keycloak JS adapter 공식 문서: silent check-sso의 hidden iframe 메커니즘 + third-party cookie 의존 + Safari 13.1+ fallback (D3 MECHANISM 근거)
|
||||
- [[raw/official-docs/chrome-third-party-cookie-policy-google-official]] — Google Privacy Sandbox (2025-04-22): Chrome은 3rd-party cookie 기본 차단 계획 **철회**(no new standalone prompt), Incognito만 기본 차단 → D3a (Chrome 일반 모드 silent renew 현재 동작) 근거 + "Chrome phase-out" 통념 정정
|
||||
- [[raw/official-docs/oauth2-pkce-rfc-7636]] — RFC 7636: `code_verifier` = per-request 생성·기록 secret (D6 verifier lifetime 근거)
|
||||
- [[raw/official-docs/oauth2-browser-based-apps-ietf-draft]] — OAuth 2.0 for Browser-Based Apps BCP: §8 은 access/refresh **token** 저장만 다루고 `code_verifier` 는 0회 언급 → D6 의 "sessionStorage 는 BCP 직접 권고 아님(INFERENCE)" 근거
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [ ] **4 저장소 노출 비교표** — 등급: `documented-only`
|
||||
| 저장소 | JS 접근 | XSS 노출 | CSRF 노출 | reload 후 유지 | 적합 토큰 |
|
||||
|--------|---------|----------|-----------|----------------|-----------|
|
||||
| `localStorage` | ✅ | **높음** (모든 JS) | 낮음 (자동 첨부 안 됨) | ✅ 영구 | **권장 안 함** |
|
||||
| `sessionStorage` | ✅ | **높음** (탭별, 모든 JS) | 낮음 | ✅ 탭 내 | (권장 안 함, 단 PKCE verifier는 가능) |
|
||||
| **메모리 (JS 변수)** | ✅ | 낮음 (런타임만, debugger 접근 가능하나 영속 X) | 낮음 | ❌ 잃음 | **pure SPA access/refresh baseline** |
|
||||
| **httpOnly secure cookie** | ❌ | **낮음** (JS 접근 불가) | **높음** (자동 첨부) → `SameSite` + CSRF 방어 필요 | ✅ cookie TTL | **TMB/BFF variant only** |
|
||||
- [ ] **refresh_token 저장 권고** — 등급: `documented-only`
|
||||
- pure SPA baseline: **메모리 only**. reload 시 재인증
|
||||
- TMB/BFF variant: **httpOnly + Secure cookie**. server-side endpoint와 CSRF 계약을 함께 소유할 때만
|
||||
- 절대 금지: localStorage / sessionStorage (RFC 6749 §10.4 refresh_token confidentiality)
|
||||
- [ ] **access_token 저장 권고** — 등급: `documented-only`
|
||||
- 권장: **메모리 (JS 변수 / closure)** — reload 시 silent refresh로 재취득
|
||||
- TTL: 5~15분 (짧을수록 탈취 시 피해 감소)
|
||||
- [ ] **OAuth 2.1 draft 인용** — 등급: `documented-only`
|
||||
- *"Refresh tokens MUST be sender-constrained or use refresh token rotation."*
|
||||
- SPA 환경에서는 sender-constrained(mTLS / DPoP) 어렵 → **rotation 의존**
|
||||
- [ ] **SPA reload 시 흐름 옵션** — 등급: `documented-only`
|
||||
- baseline: memory 소실 → 사용자 재인증
|
||||
- 대안: silent SSO는 별도 브라우저/배포 조건 검증
|
||||
- variant: httpOnly refresh cookie + `/refresh`는 TMB/BFF로 분류
|
||||
- [ ] **Silent renew 함정** — 등급: `documented-only`
|
||||
- 1st-party context: Keycloak이 same-site면 동작
|
||||
- 3rd-party context: Safari ITP / Chrome 3rd-party cookie phase-out → Keycloak SSO cookie를 iframe에서 못 읽음 → silent renew 실패
|
||||
- **정정 (2026-07-18, → D3/D3a)**: "Chrome 3rd-party cookie phase-out" 은 부정확 — Google 2025-04-22 발표로 Chrome 일반 모드는 기본 **미차단**(Incognito 만 차단). cross-site 기본 차단이 확정된 것은 **Safari(ITP)** 뿐. Decision Evidence Map D3(Safari) + D3a(Chrome) 참조. [[raw/official-docs/chrome-third-party-cookie-policy-google-official]]
|
||||
- 대안: refresh_token grant 직접 사용 (cookie 또는 메모리)
|
||||
- [ ] **XSS 발생 시 시나리오** — 등급: `documented-only`
|
||||
- localStorage: 즉시 토큰 탈취 + 영속 (브라우저 종료 후에도)
|
||||
- 메모리: 현재 페이지 세션 내 탈취 (이후 fetch 후킹은 가능하나 영속 X)
|
||||
- httpOnly cookie: JS 접근 불가지만 `fetch(/api, {credentials: 'include'})`로 공격자가 SPA 도메인 내에서 API 호출은 가능 → CSRF 토큰으로 추가 방어
|
||||
- [ ] **CSRF 방어 (cookie 사용 시)** — 등급: `documented-only`
|
||||
- `SameSite=Strict` (cross-site 자동 첨부 차단)
|
||||
- + double-submit CSRF token (header X-CSRF-Token)
|
||||
- + Origin / Referer 검증
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
작업하며 떠오른 메모. 자유 형식.
|
||||
|
||||
- "메모리 저장은 안전하다"는 단순 명제는 아님 — XSS 페이로드가 fetch wrapper를 후킹하면 메모리에 있어도 모든 요청이 가로채짐. 단 영속성은 없음 (reload 시 사라짐).
|
||||
- BFF 패턴이 사실상 가장 깔끔한 해법이지만 백엔드 stateful + session 공유 필요 → P2A 본 branch에서는 SPA Direct를 채택했음.
|
||||
- PKCE `code_verifier`는 매우 단명(seconds) → sessionStorage도 허용 가능 (단 메모리가 더 안전).
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
> 추후 면접/회고에서 "왜 이렇게 했나" 답할 근거. 대안과 함께 기록.
|
||||
|
||||
- 2026-05-25 (historical, superseded): ~~P2A 권장 조합 = access_token 메모리 + refresh_token secure HttpOnly cookie~~.
|
||||
- 2026-07-18: **pure SPA baseline = access/refresh token 모두 memory-only, reload 시 재인증**. HttpOnly refresh cookie는 최소 TMB/BFF variant이며 AP1 baseline에 포함하지 않는다.
|
||||
- 2026-07-18: TMB/BFF variant를 채택할 때만 별도 cookie/CSRF owner를 지정한다. 현재 branch set에는 그 구현 owner가 없으므로 `OWNER_REQUIRED`로 남긴다.
|
||||
- 2026-05-25: localStorage 사용은 **모든 토큰에 대해 금지**로 기록 (OWASP).
|
||||
- 2026-05-25: silent renew는 3rd-party cookie 제약으로 long-term 권장 안 함 → refresh_token grant 직접 사용 우선.
|
||||
- 2026-07-18 (`/branch-spec` 자동조사 보강): D3 를 브라우저·토폴로지 조건부로 **정밀화**. (1) Keycloak **same-site** 면 silent renew 동작 / **cross-site + Safari** 는 ITP 로 구조적 실패(어댑터가 full-redirect fallback) → refresh_token grant 우선. (2) **정정** — "Chrome 3rd-party cookie phase-out" 전제는 부정확: Google 2025-04-22 발표로 Chrome 일반 모드는 기본 **미차단**(Incognito 만 차단) → 신규 **D3a** 로 분리. 근거: [[raw/official-docs/third-party-cookie-blocking-safari-webkit-official]], [[raw/official-docs/silent-check-sso-third-party-cookies-keycloak-official]], [[raw/official-docs/chrome-third-party-cookie-policy-google-official]].
|
||||
- 2026-07-18 (`/branch-spec` 자동조사 보강): D6 를 `UNSUPPORTED` 에서 해소 — full-page redirect 전제에서 PKCE `code_verifier` 저장 = **sessionStorage** (in-memory 는 redirect 생존 불가, localStorage 는 OWASP 반대). 단 **"BCP 직접 권고 아님(INFERENCE)"** 명시 — Browser-Based Apps BCP 는 `code_verifier` 를 언급하지 않음. 근거: `OWASP-HTML5-C4` + `PKCE-RFC7636-C2`.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정의 직접 근거. `선택 조건` 열(R2): 이 조건일 때 이 결정 / 다른 조건이면 어떤 대안. Strength 어휘: OWASP cheatsheet = `official-reference`, OAuth 2.1 / RFC 7636 = `official-standard`, WebKit / Chrome / Keycloak vendor doc = `official-vendor-doc`, Curity blog = `company-case-study`. company-tech-blog 단독으로 "공식 best practice" 단언 금지. **D6 의 저장 위치 권고는 `INFERENCE`** — BCP 직접 문장이 아니라 OWASP 원칙 + verifier lifetime + 실무 관행의 사슬(하단 Open Risk 참조).
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | pure SPA baseline = access/refresh token 모두 memory-only, reload 시 재인증 | server-side token custody가 없는 AP1이면 이 결정. 세션 지속이 필수면 D7의 TMB/BFF variant로 패턴을 바꾼다 | `raw/official-docs/owasp-html5-storage-xss-spa.md#OWASP-HTML5-C1`, `#OWASP-HTML5-C2`, `#OWASP-HTML5-C3` | `official-reference + project architecture decision` | memory token도 실행 중 XSS에 노출된다. 이 선택은 persistence를 제거할 뿐 XSS 자체를 제거하지 않음 |
|
||||
| D2 | localStorage 사용은 모든 토큰에 대해 금지 | N/A (무조건) — XSS 위협 모델을 가정하는 모든 SPA. XSS 를 위협 모델에서 완전 배제 가능하면 예외 후보이나 OWASP 는 그 가정 불허 | `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-C3` | `official-reference` | sessionStorage 도 동일 위협 (`OWASP-HTML5-C4` Does not prove: "sessionStorage 가 XSS 에 안전하다는 뜻은 아님 — `C2`/`C3` 는 these objects 즉 둘 다에 적용"). 본 branch 본문 표의 "sessionStorage XSS 노출 높음" 은 정합 |
|
||||
| D3 | Keycloak hidden-iframe silent renew(`prompt=none`)는 **Keycloak cross-site + Safari** 에서 구조적으로 실패 → refresh_token grant 직접 사용(rotation 의존) 우선 | Keycloak **same-site**(SPA 와 동일 registrable domain) → silent renew 동작(유지 가능). Keycloak **cross-site + Safari**(ITP) → 실패(어댑터가 full-redirect fallback → "silent" 상실) → refresh_token grant. Chrome cross-site 는 D3a 참조 | `raw/official-docs/silent-check-sso-third-party-cookies-keycloak-official.md#KC-JSADAPTER-C1`, `...#KC-JSADAPTER-C2`, `...#KC-JSADAPTER-C3`, `...#KC-JSADAPTER-C4`, `...#KC-JSADAPTER-C5`, `raw/official-docs/third-party-cookie-blocking-safari-webkit-official.md#WEBKIT-3PC-C1`, `...#WEBKIT-3PC-C3` | `official-vendor-doc` (Keycloak + WebKit) | same-site vs cross-site 판정 단위(eTLD+1)의 직접 인용은 본 Sources 에 미확보(WebKit 블로그에 없음 → `webkit.org/tracking-prevention` 별도 아카이빙 필요, **Should-fix**). "silent renew 는 항상 안 된다" 는 과장 — same-site 배포면 동작. refresh_token grant 의 저장 위치 문제는 D1 · §엣지 참조 |
|
||||
| D3a | Chrome 은 (2026-07-18 조사 시점 stated policy) **일반 모드에서 3rd-party cookie 기본 미차단**(Incognito 만 차단) → "Chrome 3rd-party cookie phase-out" 통념은 부정확 | Chrome 일반 모드 + cross-site → silent renew 현재 동작(단 정책 불안정). Chrome Incognito → 차단 → 실패. 사용자가 수동 3PC off → 브라우저 무관 실패 | `raw/official-docs/chrome-third-party-cookie-policy-google-official.md#CHROME-3PC-C1`, `...#CHROME-3PC-C3` | `official-vendor-doc` | Google 정책은 2020~2025 수차례 번복(2025-04-22 철회) → "확정적 장기 사실" 인용 금지, "조사 시점 stated policy" 로만. 장기 아키텍처를 현재 Chrome 정책에 고정하는 것 비권장. (skycloak.io 등 "Chrome deprecation 중" 주장은 이 공식 vendor 소스와 상충 → 채택 안 함) |
|
||||
| D4 | refresh_token 은 rotation 에 의존 (sender-constrained mTLS/DPoP 어려움) | SPA(public client)라 sender-constrained(mTLS/DPoP) 어려움 → rotation. mTLS/DPoP 지원 환경(confidential client 전환 등)이면 sender-constrained 상위. rotation 메커니즘·재사용탐지는 sibling [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] 위임 | `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C3` (refresh token 은 scope + resource server 에 bound MUST), `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C4` (BFF 권고) | `official-standard` | 본문 "Refresh tokens MUST be sender-constrained or use refresh token rotation" 의 직접 verbatim 은 본 branch Sources 의 OAuth 2.1 발췌(OA21-C1~C6)에 미포함 — sibling refresh-token-rotation branch 가 rotation 상세를 owns. 현 D4 는 OA21-C3(bound) + OA21-C4(BFF)로 부분 corroborate |
|
||||
| D5 | refresh token 탈취 시 유효 기간 동안 victim 데이터 접근 가능 — SPA Direct 의 핵심 위험 | N/A (위험 진술). 이 위험 감수 불가 → BFF 전환(refresh_token 을 브라우저에서 제거, D1 대안) | `raw/company-tech-blogs/curity-bff-pattern-spa.md#CURITY-BFF-C6`, `raw/company-tech-blogs/curity-bff-pattern-spa.md#CURITY-BFF-C2` | `company-case-study` | Curity vendor 권고. 공식 표준 측 corroborate 는 `OA21-C3`(refresh token binding MUST)와 결합. rotation mitigation 효과는 본 인용 미포함(sibling 위임) |
|
||||
| D6 | PKCE `code_verifier` 저장 = **sessionStorage** (full-page redirect 전제) — in-memory 는 redirect 생존 불가, localStorage 는 OWASP 의 "persistence 불필요 시 sessionStorage" 조건에 반함 | full-page redirect flow(탭 전체 navigate) → 메모리 verifier 파괴 → sessionStorage. popup/iframe 로 부모 탭 메모리 유지 가능하면 in-memory 가 더 안전. multi-tab 로그인 UX 요구 → cookie transaction(Auth0 `useCookiesForTransaction`) 별도 검토(N=3 범위 밖) | `raw/official-docs/owasp-html5-storage-xss-spa.md#OWASP-HTML5-C4` (persistence 불필요 시 sessionStorage), `raw/official-docs/oauth2-pkce-rfc-7636.md#PKCE-RFC7636-C2` (verifier = 생성·기록 per-transaction secret) | `official-reference(OWASP) + official-standard(RFC 7636) + INFERENCE(저장 위치)` | **"sessionStorage 가 BCP 권고" 표현 금지** — OAuth 2.0 for Browser-Based Apps BCP 는 `code_verifier` 를 0회 언급(§8 은 token 전용, `oauth2-browser-based-apps-ietf-draft` 확인). 저장 위치 결정은 OWASP 일반 원칙 + verifier lifetime + 실무 관행의 **inference 사슬**이지 단일 official 직접 인용 아님. verifier(sessionStorage) 탈취는 authorization code 없이 무가치 → token 탈취보다 심각도 낮음 |
|
||||
| D7 | HttpOnly refresh cookie는 TMB/BFF variant에서만 허용 | reload 없는 세션 지속이 memory-only UX보다 중요하고, server-side `/refresh`·cookie lifecycle·CSRF negative test owner를 함께 둘 때만. 그 계약이 없으면 D1 유지 | `raw/company-tech-blogs/curity-bff-pattern-spa.md#CURITY-BFF-C1`, `#CURITY-BFF-C6` | `company-case-study + architecture boundary` | 현재 구현 owner 없음(`OWNER_REQUIRED`). audience-validator는 bearer 검증 owner이지 cookie/CSRF owner가 아님 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 본 branch 는 `documented-only` — 여기서의 "구현" 은 다운스트림 구현 branch([[raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce]])가 소비할 **저장 위치 배치 명세**다. 각 row 는 Decision ID + Supporting Claim ID 를 Trace 하거나 `UNSUPPORTED_IMPL_DECISION` 라벨을 단다(CLAUDE.md §15.5 3-rule).
|
||||
|
||||
### 1. 토큰·secret 저장 위치 배치 명세
|
||||
|
||||
> **Trace**: D1 (`OWASP-HTML5-C1`/`C2`/`C3`, `CURITY-BFF-C6`), D2 (`OWASP-HTML5-C1`~`C3`), D6 (`OWASP-HTML5-C4`, `PKCE-RFC7636-C2`)
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (1) refresh_token cookie 의 `Path` 범위, (2) verifier sessionStorage 키명 — 아래 표에 개별 명시.
|
||||
|
||||
| 대상 | 저장 위치 | 속성 / 키 | Trace | 라벨 |
|
||||
|---|---|---|---|---|
|
||||
| access_token | 메모리 (모듈 스코프 closure 변수, non-exported) | reload 시 §2 흐름으로 재취득 | D1 / `OWASP-HTML5-C1`·`C2` | — |
|
||||
| refresh_token | 메모리 (access_token과 동일한 in-memory store) | reload 시 폐기하고 재인증 | D1 / `OWASP-HTML5-C1`·`C2` | — |
|
||||
| localStorage / sessionStorage | 토큰 저장 **금지** | — | D2 / `OWASP-HTML5-C1`·`C2`·`C3` | — |
|
||||
| PKCE `code_verifier` | sessionStorage | 토큰 교환 성공 즉시 `removeItem` | D6 / `OWASP-HTML5-C4`, `PKCE-RFC7636-C2` | `UNSUPPORTED_IMPL_DECISION`: 키명(예 `kc_pkce_verifier`)은 임의 — trade-off: 키에 `state` 포함(`...-${state}`)하면 multi-tab 동시 로그인 충돌 방지(Auth0 관행), 고정키는 단순하나 탭 충돌 |
|
||||
|
||||
> HttpOnly refresh cookie는 D7 variant다. backend `/refresh`가 `Set-Cookie`하고 CSRF를 검증해야 하므로 pure SPA 배치표에 섞지 않는다.
|
||||
|
||||
### 2. reload 후 access_token 재취득 흐름
|
||||
|
||||
> **Trace**: D1, D3 (Safari cross-site: silent renew 실패), D3a (Chrome normal-mode: 현재 미차단이나 정책 불안정)
|
||||
|
||||
| 옵션 | 흐름 | 언제 이 옵션 | Trace |
|
||||
|---|---|---|---|
|
||||
| (a) 같은 page session의 refresh_token grant | memory refresh_token으로 새 access_token을 받아 둘 다 memory에 갱신 | reload 전 활성 session에서 rotation을 시연할 때 | D1/D4, sibling [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] |
|
||||
| (b) silent renew (hidden iframe + `prompt=none`) | iframe 에서 Keycloak SSO cookie 로 재발급 | Keycloak **same-site**(eTLD+1 동일)일 때 안정. Chrome normal cross-site 도 현재 동작하나 정책 불안정 | D3, D3a |
|
||||
| (c) 메모리 only + 재인증 | reload 시 토큰 소실 → 사용자 재인증 | **pure SPA baseline** | D1 |
|
||||
|
||||
### 3. TMB/BFF variant의 cookie·CSRF 선행 계약
|
||||
|
||||
> **Trace**: D1 (`OWASP-HTML5-C5`: cookie 는 path 제한 가능하나 CSRF 는 별도 surface)
|
||||
>
|
||||
> - **OUT_OF_BRANCH_SCOPE / OWNER_REQUIRED**: pure SPA에는 이 계약을 적용하지 않는다. D7 variant를 채택할 때 `/refresh`, `Set-Cookie`, logout/revoke, CSRF token 발급·검증과 negative test를 한 별도 owner D-row에 먼저 배정한다. [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]]는 bearer JWT 검증 owner이므로 목적지가 아니다.
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: double-submit vs synchronizer token 선택은 본 Sources 직접 근거 없음 — Spring 기본은 synchronizer. trade-off: double-submit 은 stateless(세션 불요)하나 XSS 에 상대적으로 약함.
|
||||
|
||||
- 저장측 요구: `SameSite=Strict` + double-submit CSRF token(요청 header) + Origin/Referer 검증.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4(깊이 게이트) 캡처용. 정상 경로 외 실패/엣지/다른 계약 의존.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **XSS 발생 시**:
|
||||
- localStorage/sessionStorage 토큰: 즉시 전량 탈취(+ localStorage 는 영속) — D2 (`OWASP-HTML5-C2`/`C3`)
|
||||
- 메모리 access_token: 런타임 XSS 가 fetch wrapper 후킹 시 세션 내 탈취 가능, 단 영속 X(reload 소멸)
|
||||
- D7 variant의 HttpOnly cookie: JS가 raw token을 읽지 못해도 XSS가 활성 session으로 요청을 대행할 수 있고 browser 자동 첨부로 CSRF surface가 생김 → 별도 owner 계약 필요
|
||||
- PKCE verifier(sessionStorage): 탈취돼도 authorization code 없이는 무가치 → token 탈취보다 심각도 낮음(D6 INFERENCE 근거)
|
||||
- **reload**: 메모리 access_token 소실 → §구현가이드 2 재취득 필수. 재취득 실패 시 재로그인.
|
||||
- **silent renew 실패**: Keycloak **cross-site + Safari ITP** → hidden iframe 이 SSO cookie 못 읽음 → Keycloak 어댑터가 full redirect 로 fallback("silent" 상실). Chrome normal-mode 는 현재 동작(D3a)하나 정책 변동 리스크.
|
||||
- **refresh_token 탈취**: 유효기간 내 victim 데이터 접근(D5, `CURITY-BFF-C6`) → rotation 재사용 탐지(sibling 위임).
|
||||
- **PKCE verifier 소실**: full-page redirect 가 메모리 verifier 파괴 → sessionStorage 필수(D6). 콜백에서 verifier 부재 시 token 교환 실패(`PKCE-RFC7636-C4`: "Access is denied if they are not equal").
|
||||
- **동시성**: multi-tab 동시 로그인 → sessionStorage 탭 격리로 verifier 충돌 방지(D6 채택 이유); refresh_token grant rotation 시 동시 refresh race(두 번째 요청이 무효화 토큰 사용) — rotation 구현 detail 은 sibling 위임.
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-keycloak-refresh-token-rotation]] `D1`(rotation 활성화 = `Revoke Refresh Token ON` + reuse detection)·`D4`(rotation flow 4단계: RT 사용→invalidate→재발급→재사용 시 family invalidate) — 본 branch D4/D5 의 mitigation 을 이 sibling 이 owns. 본 branch 는 "rotation 에 의존"만 결정하고 재사용탐지·TTL 은 consume. 그 계약(rotation 활성/family invalidate 범위)이 바뀌면 D4/D5 위험 평가에 영향.
|
||||
- [[raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce]] — 실제 SPA 가 본 §구현가이드 배치 명세를 구현. 본 branch 의 §구현가이드 = 그 branch 의 입력 계약.
|
||||
- [[raw/branch-notes/feature-keycloak-bff-vs-spa-direct]] — BFF 대안. 본 branch 는 SPA Direct 전제. BFF 채택 시 토큰이 브라우저에 없어 D1/D2/D6 대부분 무효화.
|
||||
- D7 variant의 cookie/CSRF 구현 owner는 아직 없음(`OWNER_REQUIRED`). 채택 전 별도 계약을 만들어야 하며 pure SPA baseline의 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]]에 암묵적으로 부과하지 않는다.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| pure SPA에서 access/refresh token을 memory-only로 두고 reload 시 재인증하는 baseline | 문서 근거는 있으나 실 SPA 구현 없음 | [[raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce]]에서 로그인→API→reload→user/token 소실→재인증을 E2E 확인 | `planned` |
|
||||
| D7 HttpOnly refresh-cookie variant의 server endpoint·CSRF 계약 | 현재 owner와 구현 artifact가 없음 | variant owner branch와 D-row를 먼저 만든 뒤 `/refresh` Set-Cookie, CSRF negative test, logout/revoke E2E 확인 | `blocked-on-owner` |
|
||||
| Safari cross-site silent renew 실패와 Chrome 조사시점 정책의 runtime 동작 | D3/D3a의 vendor 근거는 확보됐지만 본 topology E2E 미실행 | same-site/cross-site를 나눠 Safari와 Chrome에서 hidden iframe/full redirect를 관측 | `planned (source-resolved, runtime-unverified)` |
|
||||
| D7 variant의 CSRF 방어 조합 | pure SPA 범위 밖이고 owner 미정 | owner 지정 후 위협 모델에 맞는 SameSite/CSRF token/Origin 검증과 negative E2E를 명세 | `blocked-on-owner` |
|
||||
| PKCE `code_verifier` sessionStorage 선택 | RFC·OWASP 근거 사슬은 확보됐지만 직접 BCP 권고가 아닌 inference | full-page redirect 전후 verifier 생존과 callback 직후 제거를 E2E 확인 | `planned (inference-grounded, runtime-unverified)` |
|
||||
| 본 branch 4 저장소 비교표의 각 셀이 OWASP 또는 OAuth 2.1 draft 의 정확한 quote 로 직접 뒷받침되는지 | 본문 표는 종합 판단 — 셀별 source mapping 부재 | 각 셀마다 supporting claim 표기 또는 본 branch 본문 통찰임을 명시 | `planned` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 이슈 1: 메모리 저장은 reload 시 토큰을 잃음 → UX 저하 vs 보안 trade-off.
|
||||
- 원인: SPA가 매 reload마다 새로 부트스트랩되므로 closure 변수는 사라짐
|
||||
- 시도: (구현 없음)
|
||||
- 해결: pure SPA baseline은 reload 시 재인증. 무중단 UX가 필수면 D7 TMB/BFF variant를 별도 채택 — `documented-only`
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/chrome-third-party-cookie-policy-google-official]]
|
||||
- [[raw/official-docs/oauth-v2-1-draft-ietf]]
|
||||
- [[raw/official-docs/owasp-html5-storage-xss-spa]]
|
||||
- [[raw/official-docs/silent-check-sso-third-party-cookies-keycloak-official]]
|
||||
- [[raw/official-docs/third-party-cookie-blocking-safari-webkit-official]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
> 본 sub-sub-branch 는 leaf — 자식 자료 없음. Phase 3 P3A 실 구현 또는 외부 산출물 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 근거 자료
|
||||
|
||||
- [[raw/official-docs/owasp-html5-storage-xss-spa]]
|
||||
- [[raw/official-docs/oauth-v2-1-draft-ietf]]
|
||||
- [[raw/company-tech-blogs/curity-bff-pattern-spa]]
|
||||
- [[raw/official-docs/chrome-third-party-cookie-policy-google-official]] — D3 UNSUPPORTED_DECISION 정정 근거: Chrome 은 2025-04-22 기준 default third-party-cookie blocking 을 롤아웃하지 않음(일반 모드는 여전히 허용, Incognito 모드만 기본 차단)
|
||||
|
||||
### 오류 기록 (이 sub-sub-branch 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 documented-only 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — Phase 3 실 구현 단계에 누적)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 머지/종료 시점에 채움. `/ingest`가 이 섹션을 기준으로 wiki/projects/에 추출.
|
||||
|
||||
- PR 링크: (미구현 — 문서까지만)
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: 없음 (P2A는 `documented-only` 범위)
|
||||
- **wiki 추출 대상**: 현 단계 없음. 추후 `wiki/concepts/spa-token-storage-trade-off.md`로 합성 후보.
|
||||
- **추출하지 않을 항목**: P2A 구현 없음. `documented-only` 유지.
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
---
|
||||
title: branch / feature-keycloak-spring-rs-audience-validator (Spring Security Resource Server + audience validator)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-PATTERNS-OVERVIEW-004
|
||||
kind: project-work-item
|
||||
project: keycloak-patterns-overview
|
||||
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-004
|
||||
inherits: [DEC-KEYCLOAK-PATTERNS-OVERVIEW-SPA-CLIENT-001@1, DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-KEYCLOAK-PATTERNS-OVERVIEW-003]
|
||||
contract_packet: 1
|
||||
branch: feature-keycloak-spring-rs-audience-validator
|
||||
parent_branch:
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, p2a, spring-security, resource-server, jwt, audience]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 792d7570a248139de64c5bc1fd29f79218e3eea3388db85a4d18e6175344e3b3
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-spring-rs-audience-validator — Spring Security Resource Server + audience validator
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/project-notes/keycloak-patterns-overview]]의 `WI-KEYCLOAK-PATTERNS-OVERVIEW-004` 직접 branch.
|
||||
> **목적**: Spring Security Resource Server 기본 JWT validator가 검증하는 항목과 별도 활성화가 필요한 `aud`를 분리한다. 단일 audience는 Boot `audiences` property를 baseline으로, 복합 조건은 custom `OAuth2TokenValidator<Jwt>`로 구현한다.
|
||||
> `status_label`: `in-progress` | `review` | `merged` | `abandoned`
|
||||
|
||||
> **정합 노트 (2026-07-14 감사)**: 본 노트 = AP1 의 **`aud` 검증 + Spring RS 공통 셋업 owner** (hub Branch 분해 Tier-2 `feature-keycloak-spring-rs-audience-validator`). 형제 [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] 와 RS 셋업 내용이 겹치는데, 그쪽의 **role → 권한(RBAC)** 부분은 §5 **deferred authZ 트랙**으로 분리됨. 구현 시 RS 공통 코드·`aud` 검증은 본 노트가 owner.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/project-notes/keycloak-patterns-overview]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: foreign audience token 수용 실패를 재현하고 validator 적용 후 401을 검증한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-SPA-CLIENT-001@1` | AP1은 public client와 Authorization Code + PKCE를 사용한다 | AP1 Resource Server의 JWT audience 검증에 적용한다 | [[raw/project-notes/keycloak-patterns-overview]] |
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1` | done-bar는 E2E success와 signature security failure 재현·해결 evidence다 | foreign audience token 실패 재현과 401 evidence를 완료 조건으로 사용한다 | [[raw/project-notes/keycloak-patterns-overview]] |
|
||||
|
||||
<!-- section-id: branch-local-decisions -->
|
||||
### 브랜치 지역 결정
|
||||
|
||||
> 기존 branch-local 결정은 아래 `## Decision Evidence Map`의 D-row가 소유하며 이 packet에서 복제하지 않는다.
|
||||
|
||||
| Decision ID | Decision | Relation | Supporting Claims | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
<!-- section-id: declared-overrides -->
|
||||
### 선언한 예외
|
||||
|
||||
| Override ID | Overrides | Reason | Approval | Status |
|
||||
|---|---|---|---|---|
|
||||
|
||||
없음.
|
||||
<!-- GENERATED: branch-contract:end -->
|
||||
|
||||
<!-- section-id: branch-goal -->
|
||||
## 목표
|
||||
|
||||
Spring Security 6.x Resource Server는 `spring-boot-starter-oauth2-resource-server` + `issuer-uri` 설정만으로 자동으로 JWT signature / `iss` / `exp` / `nbf`를 검증한다. 그러나 **`aud` claim 검증은 기본 활성화 안 됨**. 같은 Keycloak realm 내 다른 client용으로 발급된 토큰이 본 backend로 흘러들어도 통과될 위험이 있다 (cross-client token reuse).
|
||||
|
||||
핵심 질문:
|
||||
|
||||
- Spring Security 기본 `JwtDecoder`가 검증하는 것 vs 검증하지 않는 것?
|
||||
- `aud` claim은 왜 별도로 검증해야 하는가? (cross-client / cross-resource-server token reuse 차단)
|
||||
- 다중 issuer 환경(multi-realm)에서 어떻게 처리하는가?
|
||||
- JWKS cache 정책 (TTL, refresh, key rotation) 기본값은?
|
||||
|
||||
본 sub-sub-branch는 **의존성 → yml 설정 → 단일 audience property baseline → 복합 조건용 validator 비교**까지 정리한다. 프로젝트 expected audience의 단일 심볼은 `backend-client-id`다.
|
||||
|
||||
- 이슈: (학습 노트, 이슈 없음)
|
||||
- PR: (구현 없음)
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- **Spring Security Resource Server 공통 셋업의 owner** — 의존성(`spring-boot-starter-oauth2-resource-server`) + `application.yml` 의 `issuer-uri` + `JwtDecoder` 빈 커스터마이즈(D6). 형제 [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] 가 이 셋업을 fold-in 으로 위임(정합 노트 2026-07-14).
|
||||
- **`aud` claim 검증**(본 branch 고유 핵심, D1) — Spring 기본이 검증하지 않는 audience 를 Boot `audiences` property 또는 custom `OAuth2TokenValidator<Jwt>` 로 추가해 cross-client / cross-resource-server token reuse 를 차단.
|
||||
- **Keycloak 발급 측 `aud` 주입 요건**(D4) — SPA client 의 client scope 에 Audience mapper 를 등록해 backend client_id 가 `aud` 에 포함되도록. 발급 설정은 검증 성립의 선행 조건.
|
||||
- **검증 항목 매트릭스**(§구현 가이드 §3) — signature/`iss`/`exp`/`nbf` 는 `issuer-uri` 로 자동, `aud`/`azp`/`scope` 는 수동 추가 대상임을 분리.
|
||||
|
||||
### 제외 범위
|
||||
|
||||
> 의도적으로 제외한 것. 면접 등에서 "이건 범위에 없었습니다"라고 답할 근거.
|
||||
|
||||
- **role → 권한(RBAC) 매핑**(`realm_access.roles` → `@PreAuthorize`) — 형제 [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] 의 deferred authZ 트랙 소관(D3). 본 노트는 authN 토큰 검증까지만.
|
||||
- **다중 issuer / multi-realm**(`JwtIssuerAuthenticationManagerResolver`) — 단일 realm 학습 범위 밖(D2, `UNSUPPORTED_DECISION`).
|
||||
- **prod JWKS custom cache**(Caffeine 등) 튜닝 — 기본 cache 동작만 문서화(D5). 커스텀 cache 는 범위 밖.
|
||||
- **실 구현 / 배포** — 실제 코드는 P3A [[raw/branch-notes/feature-keycloak-single-ec2-no-google]] + 형제 role-mapping 이 별도 keycloak-patterns repo(현재 미생성)에서 수행. 본 노트는 `documented-only` 설계·계약 층.
|
||||
- **PKCE 발급 흐름 / 토큰 저장 위치 / refresh rotation** — 각 형제 sub-sub-branch owner 소관([[raw/branch-notes/feature-keycloak-pkce-flow-stages]] · [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] · [[raw/branch-notes/feature-keycloak-refresh-token-rotation]]). 본 노트는 발급된 토큰의 **검증 측**만.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
- [[raw/official-docs/spring-security-resource-server-jwt]] — Spring Security Resource Server JWT 검증 reference
|
||||
- [[raw/official-docs/oauth-v2-1-draft-ietf]] — OAuth 2.1 (audience binding 권고)
|
||||
- [[raw/official-docs/keycloak-securing-apps-overview-official]] — Keycloak audience mapper
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기: `actually-implemented` | `locally-verified` | `prod-verified` | `documented-only` | `planned` | `needs-confirmation`
|
||||
|
||||
- [ ] **의존성 정리** — 등급: `documented-only`
|
||||
- `org.springframework.boot:spring-boot-starter-oauth2-resource-server`
|
||||
- (선택) `org.springframework.security:spring-security-oauth2-jose` — 자동 포함
|
||||
- Java 21 / Spring Boot 3.x / Spring Security 6.x 가정
|
||||
- [ ] **`application.yml` issuer-uri 설정** — 등급: `documented-only`
|
||||
```yaml
|
||||
spring:
|
||||
security:
|
||||
oauth2:
|
||||
resourceserver:
|
||||
jwt:
|
||||
issuer-uri: https://<keycloak-host>/realms/<realm>
|
||||
```
|
||||
- 효과: Keycloak `/.well-known/openid-configuration` 자동 fetch → JWKS endpoint 발견 → JwtDecoder 자동 구성
|
||||
- 자동 검증: signature + `iss == issuer-uri` + `exp` + `nbf` (clock skew 60s)
|
||||
- [ ] **JwtDecoder 빈 (복합 조건일 때만 커스터마이즈)** — 등급: `documented-only`
|
||||
- 기본 빈에 `OAuth2TokenValidator<Jwt>` 체인 추가
|
||||
- `NimbusJwtDecoder.withIssuerLocation(issuerUri).build()` 사용
|
||||
- `JwtValidators.createDefaultWithIssuer(issuerUri)` + custom validator를 `DelegatingOAuth2TokenValidator`로 결합
|
||||
- [ ] **단일 audience baseline** — `spring.security.oauth2.resourceserver.jwt.audiences: backend-client-id` — 등급: `documented-only`
|
||||
- [ ] **복합 audience validator 비교 학습 sketch** — 등급: `documented-only`
|
||||
```java
|
||||
public class AudienceValidator implements OAuth2TokenValidator<Jwt> {
|
||||
private final String expectedAudience;
|
||||
public OAuth2TokenValidatorResult validate(Jwt jwt) {
|
||||
if (jwt.getAudience() != null && jwt.getAudience().contains(expectedAudience)) {
|
||||
return OAuth2TokenValidatorResult.success();
|
||||
}
|
||||
return OAuth2TokenValidatorResult.failure(
|
||||
new OAuth2Error("invalid_token", "Missing required audience", null));
|
||||
}
|
||||
}
|
||||
```
|
||||
- Keycloak `aud` claim 주의: 기본은 client_id가 `aud`로 들어가지 않을 수 있음 → Keycloak Client Scope의 **Audience mapper**를 추가해야 backend client_id가 `aud`에 포함됨
|
||||
- [ ] **다중 issuer 환경 처리** — 등급: `documented-only`
|
||||
- 단일 backend가 multi-tenant인 경우: `JwtIssuerAuthenticationManagerResolver.fromTrustedIssuers(...)` 사용
|
||||
- 각 issuer마다 JwtDecoder 별도 캐싱
|
||||
- 본 P2A 학습 범위는 단일 realm 기준 — multi-realm은 SSOT §8 자신 없는 부분에 있음
|
||||
- [ ] **JWKS cache 정책** — 등급: `documented-only`
|
||||
- 기본: 5분 cache (Spring Security `NimbusJwtDecoder` 기본 `Cache-Control` 따름)
|
||||
- Keycloak 키 회전 시 `kid` mismatch 발생 → 자동 refresh (Spring Security가 unknown kid 시 JWKS 재fetch)
|
||||
- prod에서는 `JwkSetUriJwtDecoderBuilder.cache(Cache)` 로 custom cache(Caffeine 등) 권장 — 학습 범위 외
|
||||
- [ ] **검증 항목 매트릭스** — 등급: `documented-only`
|
||||
| claim | Spring 기본 | 추가 필요 |
|
||||
|-------|-------------|-----------|
|
||||
| signature | ✅ (JWKS) | — |
|
||||
| `iss` | ✅ | — |
|
||||
| `exp` / `nbf` | ✅ (skew 60s) | — |
|
||||
| `aud` | ❌ | ✅ 단일=`audiences: backend-client-id`, 복합=custom validator |
|
||||
| `azp` (authorized party) | ❌ | (선택) 단일 client 강제 시 추가 |
|
||||
| `scope` | ❌ (decoder 단계 아님) | `@PreAuthorize("hasAuthority('SCOPE_xxx')")` |
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
작업하며 떠오른 메모. 자유 형식.
|
||||
|
||||
- Keycloak의 `aud` claim 동작은 직관과 다름 — backend client는 보통 `bearer-only` 타입인데, SPA client가 backend의 client_id를 `aud`에 포함시키려면 SPA client scope에 **Audience mapper**를 추가해야 함. 안 그러면 `aud`는 `account`(realm 내장 client)만 들어감.
|
||||
- `DelegatingOAuth2TokenValidator`로 default + audience를 묶는 패턴은 Spring Security 공식 reference의 audience validation 섹션 코드 그대로 적용 가능.
|
||||
- **`/branch-spec` 채움 (2026-07-18)** — pre-template 노트를 템플릿 정합으로 보강: `선택 조건`(R2) 열 · In/Out scope · `## 구현 가이드`(§1 RS 셋업 · §2 audience validator · §3 검증 매트릭스) · `## 엣지·실패·의존` 추가. **NO_GROUND_TRUTH** — 본 branch 는 ca-tmpl 이 아니라 keycloak-patterns 학습 프로젝트이고 실 구현 repo(`/home/donghyeon/workspace/keycloak-patterns/`)가 아직 없어 전 항목 `documented-only`/`planned` 유지(코드 grep 불가). depth 게이트 = **Ready**(Blocking 0). 자가 보강: silent-bypass 엣지(validator 미합성 → `aud` 무검사 통과) + Audience mapper 프로비저닝 owner 포인터 추가. 미해소 Should-fix(연구 opt-in 필요): ① D4 의 "Keycloak 은 client_id 를 `aud` 에 자동 미포함"의 official verbatim 부재 → Keycloak Server Admin Guide §Client Scopes/Audience mapper 재발췌 필요, ② D7 의 access-token audience binding 근거가 refresh-token(`OA21-C3`)과 mismatch → OAuth 2.1 access-token best-practice § 재발췌, ③ §2 custom validator wiring 을 Spring Reference §Configuring Validation 재발췌로 supported 승격. 셋 다 `documented-only` 를 벗어나 문서 승급 전 종결 대상.
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
> 추후 면접/회고에서 "왜 이렇게 했나" 답할 근거. 대안과 함께 기록.
|
||||
|
||||
- 2026-05-25 (정합 2026-07-18): backend는 **`iss` + signature + `exp` + `aud`**를 검증한다. audience 검증 자체는 필수지만 단일 값 `backend-client-id`는 Boot `audiences` property가 baseline이고, custom validator는 다중 audience·`azp` 같은 복합 조건의 비교/확장 경로다.
|
||||
- 2026-05-25: 다중 issuer는 학습 범위 외. 단일 realm 기준 정리.
|
||||
- 2026-05-25: `JwtAuthenticationConverter`로 `realm_access.roles`를 Spring authorities로 매핑하는 것은 본 sub-sub-branch 범위에서 제외 (인가 영역).
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 본 branch Sources: Spring Security RS JWT (`official-vendor-doc`), OAuth 2.1 draft (`official-standard`), Keycloak securing apps overview (`official-vendor-doc`). 본 mapping 은 세 source 의 직접 인용 가능한 claim 만 사용.
|
||||
|
||||
> `선택 조건` 열(R2, 2026-07-18 `/branch-spec` 추가): "이 조건일 때 이 결정, 다른 조건이면 어떤 대안". 분기 없으면 `N/A`.
|
||||
>
|
||||
> **Ownership note** — 본 노트는 형제 [[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]] (부모 hub, AP1) 의 **D3(백엔드 4종 검증) 요약의 정본 owner** 이고, 형제 [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] 의 **RS 공통 셋업 + `aud` 검증** 을 fold-in 으로 흡수한다(정합 노트 2026-07-14). role→권한(RBAC)만 그쪽 deferred 트랙에 남는다.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | backend는 `iss` + signature + `exp` + `aud`를 검증한다. expected audience는 **`backend-client-id` 하나**이며 단일 값은 Boot `audiences` property가 baseline | JWT를 직접 신뢰하는 Resource Server(AP1)면 audience 검증은 필수. 다중 audience/조건부 검증이면 custom `OAuth2TokenValidator`로 확장 | `raw/official-docs/spring-security-resource-server-jwt.md#SSRS-JWT-C1`, `#SSRS-JWT-C2`, `#SSRS-JWT-C6`, `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C3` | `official-vendor-doc + official-standard` | `backend-client-id`가 실제 Audience mapper와 token `aud`에 들어가는지는 realm export/token E2E 전까지 `needs-confirmation` |
|
||||
| D2 | 다중 issuer 는 학습 범위 외, 단일 realm 기준 정리 | 단일 realm 학습 범위면 단일 `issuer-uri`. 한 백엔드가 **여러 realm(multi-tenant)** 토큰을 받으면 `JwtIssuerAuthenticationManagerResolver.fromTrustedIssuers(...)` 로 확장 — 본 학습 범위 밖(문헌으로 미조사, `UNSUPPORTED_DECISION` 유지) | UNSUPPORTED_DECISION (학습 범위 결정 — 외부 자료가 직접 뒷받침하지 않음. SSRS-JWT 의 `JwtIssuerAuthenticationManagerResolver` 언급은 본 branch raw 발췌에 포함되지 않음) | UNSUPPORTED_DECISION | 면접/포트폴리오에 multi-tenant Resource Server 경험 주장 금지. `documented-only` 등급 엄격 유지 |
|
||||
| D3 | `JwtAuthenticationConverter` 로 `realm_access.roles` 를 Spring authorities 로 매핑하는 것은 본 sub-sub-branch 범위 제외 | **authN(누구인가)까지가 본 노트**. **authZ(realm role → 권한)** 가 필요하면 형제 [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] 의 deferred authZ 트랙. Spring default 는 `scope`/`scp` 만 매핑하므로 realm role 은 어느 쪽에서 하든 converter customize 필요 | `raw/official-docs/spring-security-resource-server-jwt.md#SSRS-JWT-C4` (scope/scp → SCOPE_ prefix default 동작) — Does not prove: "Keycloak realm role 이 default 로 자동 매핑된다는 뜻은 아님 — `realm_access.roles` 는 `JwtAuthenticationConverter` customize 필요" | `official-vendor-doc` | 본 결정은 범위 분리 — Spring default 가 Keycloak realm role 을 자동 매핑하지 **않는다** 는 SSRS-JWT-C4 의 Does-not-prove 와 정합. 형제 branch `feature-keycloak-spring-rs-role-mapping` 에서 다룸 |
|
||||
| D4 | Keycloak 의 `aud` claim 에 backend client_id 가 자동 포함되지 않음 → SPA client 의 client scope 에 Audience mapper 등록 필수 | backend client_id 로 `aud` 를 검증하려는 모든 경우(= **D1 성립의 선행 조건**). Keycloak 기본은 client_id 를 `aud` 에 안 넣으므로(대신 `aud=account`) 발급 측 Audience mapper 없이는 audience 검증이 **항상 실패** → 대안 없음(발급 설정이 선행). audience 검증을 포기하면 D1 자체가 무너짐 | `raw/official-docs/keycloak-securing-apps-overview-official.md#KC-SECAPP-C1` (Keycloak 통합 일반 원칙), (보조) `raw/official-docs/spring-security-resource-server-jwt.md#SSRS-JWT-C6` (aud 검증 메커니즘) | `official-vendor-doc` | "Keycloak 기본 동작은 client_id 를 자동으로 `aud` 에 포함하지 않음" 의 직접 verbatim 은 본 branch Sources 의 KC-SECAPP-C1~C3 / SSRS-JWT-C1~C6 어디에도 없음 — Keycloak Server Administration Guide §Client Scopes / Audience mapper 정독으로 별도 corroborate 필요. 현재 본 branch 본문 운영 경험만 |
|
||||
| D5 | JWKS cache 기본 정책 (5분, kid mismatch 시 자동 refresh) | 학습/기본 환경이면 Spring 기본 cache 동작에 위임. **prod 에서 회전 빈도·가용성 SLA** 가 빡세면 custom cache(Caffeine 등)로 교체 — 본 노트 범위 밖. `UNSUPPORTED`: 기본값 수치(5분)·refetch 동작 자체가 미검증(§Claims To Verify) | UNSUPPORTED_DECISION (본 branch Source 중 SSRS-JWT-C1~C6 어디에도 "5분 cache" 또는 "kid mismatch refresh" 의 verbatim quote 없음. Spring Security `NimbusJwtDecoder` cache 동작은 별도 § 또는 source code 정독 필요) | UNSUPPORTED_DECISION | 본 branch 본문 진술 ("기본 5분 cache", "unknown kid 시 JWKS 재fetch") 은 운영 경험/추정. 정확한 verbatim source 추출 필요 |
|
||||
| D6 | `application.yml` 의 `issuer-uri` 한 줄로 OIDC discovery + JWKS 자동 fetch + iss/exp/nbf 자동 검증 (clock skew 60s) | authorization server 가 **OIDC discovery 지원**(Keycloak O)이면 `issuer-uri` 한 줄. discovery 미지원 또는 RS 가 **독립 부팅**(startup 시 AS ping 회피)을 요구하면 `jwk-set-uri` 병기(`SSRS-JWT-C5`). clock skew 는 기본값에 의존하되 시계 편차가 큰 환경이면 `JwtTimestampValidator` 로 override | `raw/official-docs/spring-security-resource-server-jwt.md#SSRS-JWT-C1`, `raw/official-docs/spring-security-resource-server-jwt.md#SSRS-JWT-C2` | `official-vendor-doc` | "clock skew 60s" 의 직접 verbatim quote 는 본 branch Source raw 발췌 (SSRS-JWT-C1~C6) 에 미포함 — Spring Security `JwtTimestampValidator` default 값. 별도 정확 확인 필요 |
|
||||
| D7 | OAuth 2.1 draft 가 audience binding 을 권고한다는 진술 | N/A — 표준 근거 진술(결정 분기 아님). access token audience binding 의 직접 quote 는 **부분 corroborate**(인용된 `OA21-C3` 는 refresh token binding) → §Claims To Verify 로 access-token 측 § 재발췌 필요 | `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C3` (refresh token MUST be bound to scope + resource servers) | `official-standard` | OA21-C3 는 refresh token binding 만 직접 다룸. access token audience binding 의 직접 권고 quote 는 OAuth 2.1 draft 의 다른 § (e.g., §4.x 또는 §5.x token best practice) 별도 정독 필요. 현재 D7 은 부분 corroborate |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 본 branch 는 `documented-only` — 실 구현은 P3A(별도 keycloak-patterns repo, 현재 미생성)로 위임(§완료 후 정리). 따라서 산출물은 실행 코드가 아니라 **다음 구현자가 되묻지 않고 코드를 쓸 수 있는 사전 명세**다. 아래 sub-section 은 본 branch 의 결정(D1·D4·D6)에서만 도출하며, 형제 owner detail 은 포인터로 위임한다(`rules/consistency-contract.md` Reference-Only). 모든 실 구현 등급은 `planned`.
|
||||
|
||||
### 1. Spring Security Resource Server 셋업 (의존성 → yml → JwtDecoder 빈)
|
||||
|
||||
> **Trace**: D6(`SSRS-JWT-C1` issuer-uri→iss self-configure, `SSRS-JWT-C2` 4단계 deterministic discovery) + D1(`SSRS-JWT-C6` audiences 로 aud 검증). 형제 [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] 가 이 셋업을 fold-in 으로 소비한다 — 본 §가 그 공통 셋업의 owner.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: clock skew 값(D6 본문의 "60s")·JWKS cache TTL(D5 의 "5분")은 인용 claim 이 보증하지 않는 Spring 기본값 — 아래 표에 `needs-confirmation` 으로 표기하고 §Claims To Verify 로 검증. 임의로 "60s/5분"을 명세에 각인하지 않는다.
|
||||
|
||||
| 단계 | 무엇 | 메커니즘 (되묻지 않을 명세) | 근거 | 등급 |
|
||||
|---|---|---|---|---|
|
||||
| 의존성 | Resource Server 활성화 | `org.springframework.boot:spring-boot-starter-oauth2-resource-server` (Java 21 / Spring Boot 3.x / Spring Security 6.x). `spring-security-oauth2-jose` 는 전이 포함 | `SSRS-JWT-C1` (RS 가 issuer-uri 로 self-configure) | `planned` |
|
||||
| yml | discovery + 자동 검증 | `spring.security.oauth2.resourceserver.jwt.issuer-uri: https://<kc-host>/realms/<realm>` → `/.well-known/openid-configuration` fetch → JWKS 발견 → signature+`iss`+`exp`+`nbf` 자동 | `SSRS-JWT-C1`, `SSRS-JWT-C2` | `planned` |
|
||||
| yml(대안) | AS ping 없이 독립 부팅 | discovery 미지원/독립 부팅이면 `jwk-set-uri` 병기 — 이때도 `issuer-uri` 는 유지(`iss` 검증 위해). startup 시 AS ping 안 함 | `SSRS-JWT-C5` | `planned` |
|
||||
| 단일 audience | property로 audience 활성화 | `spring.security.oauth2.resourceserver.jwt.audiences: backend-client-id` | D1 / `SSRS-JWT-C6` | `planned` |
|
||||
| JwtDecoder 빈 | 복합 조건일 때만 custom validator 합성 | `NimbusJwtDecoder`의 기본 validator를 보존하고 custom audience/azp 조건을 추가 | 아래 §2 `UNSUPPORTED_IMPL_DECISION` | `planned` |
|
||||
| 시간 검증 | clock skew | 기본값 사용. 편차 큰 환경만 `JwtTimestampValidator(Duration)` override | D6 Open Risk — 기본값 수치 `needs-confirmation` | `planned` |
|
||||
|
||||
### 2. Audience validator (본 branch 고유 핵심 — D1)
|
||||
|
||||
> **Trace**: D1(`SSRS-JWT-C6` — Boot `audiences` property 가 `aud` 검증을 활성화, `iss` 또는 `aud` 불일치 시 실패) + D4(`KC-SECAPP-C1` — 발급 측 설정 선행). 검증 코드 sketch 는 §TODO 의 `AudienceValidator implements OAuth2TokenValidator<Jwt>` 참조(중복 재작성 안 함).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION (핵심 갭)**: **Boot `audiences` property vs custom `OAuth2TokenValidator` 선택**. `SSRS-JWT-C6` 은 **property 방식만** 보증하고, custom validator + `DelegatingOAuth2TokenValidator` 로 default 와 합성하는 **정확한 wiring 은 인용 범위 밖**(그 claim 의 Does-not-prove 열이 "별도 §Configuring Validation 페이지 참조"로 명시). trade-off: **단일 audience** 면 property 한 줄이 단순·안전(권장), **다중 audience / 조건부(azp 병행 등)** 면 custom validator 가 필요 — 본 branch 는 학습상 custom 코드 sketch 를 보유하되 property 를 baseline 근거로 둔다. ▶ 후속(권장 next research): Spring Security Reference "Configuring Validation / Validating an Audience" 절을 `wiki-source-summarizer` 로 재발췌해 `SSRS-JWT-C7` 추가 → 이 갭을 supported 로 승격.
|
||||
|
||||
| 검증 방식 | 언제 | wiring | 근거 상태 |
|
||||
|---|---|---|---|
|
||||
| Boot `audiences` property | 단일 audience, Boot 3.x (**프로젝트 baseline**) | `spring.security.oauth2.resourceserver.jwt.audiences: backend-client-id` 한 줄 | **supported** (`SSRS-JWT-C6`) |
|
||||
| custom `OAuth2TokenValidator<Jwt>` | 다중 audience / 조건부 로직 | §TODO sketch(`jwt.getAudience().contains(expectedAudience)`) + §1 의 `DelegatingOAuth2TokenValidator` 합성 | **UNSUPPORTED_IMPL_DECISION** (wiring 인용 범위 밖 — 위 참조) + §Claims To Verify |
|
||||
| 발급 측 선행(Keycloak) | 두 방식 공통 | SPA client → Client Scopes → **Audience mapper**(Included Client Audience = `backend-client-id`) | D4 (`KC-SECAPP-C1` 보조 — runtime 확인 필요) |
|
||||
|
||||
### 3. 검증 항목 매트릭스 (무엇이 자동 / 무엇이 수동)
|
||||
|
||||
> **Trace**: D1 + D6. §TODO 의 "검증 항목 매트릭스" 를 명세로 승격 — 각 claim 이 `issuer-uri` 로 자동인지 수동 추가인지 확정.
|
||||
|
||||
| claim | Spring 기본 (`issuer-uri`) | 추가 필요 | 근거 |
|
||||
|---|---|---|---|
|
||||
| signature (JWKS) | ✅ 자동 | — | `SSRS-JWT-C2` (JWKS 로 public key 검증 strategy) |
|
||||
| `iss` | ✅ 자동 | — | `SSRS-JWT-C1`, `SSRS-JWT-C2` |
|
||||
| `exp` / `nbf` | ✅ 자동 (clock skew 기본값 — `needs-confirmation`) | — | `SSRS-JWT-C2` + D6 Open Risk |
|
||||
| `aud` | ❌ | ✅ **§2 audience validator** | `SSRS-JWT-C6` |
|
||||
| `azp` (authorized party) | ❌ | (선택) 단일 client 강제 시 | `UNSUPPORTED_IMPL_DECISION` — 본 branch Sources 에 `azp` 직접 인용 없음(§Claims To Verify). trade-off: OIDC Core §2 근거 필요, 현재 매트릭스 통찰만 |
|
||||
| `scope` | ❌ (decoder 단계 아님) | `@PreAuthorize("hasAuthority('SCOPE_x')")` | `SSRS-JWT-C4` (scope→SCOPE_ prefix) |
|
||||
| JWKS cache / kid 회전 | (기본 cache — `needs-confirmation`) | prod 는 custom cache | D5 `UNSUPPORTED_DECISION` (§Claims To Verify) |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4(깊이 게이트) 캡처용. 본 branch 는 `documented-only` 이나, audience 검증을 실제로 세울 때 부딪힐 실패/엣지와 다른 계약 의존을 미리 열거한다.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **`aud` 미검증 → cross-client token reuse**: Spring 기본 validator 는 `aud` 를 보지 않으므로(`SSRS-JWT-C6` 은 property 를 켜야 검증됨) 같은 realm 의 **다른 client 토큰**이 본 백엔드에서 통과한다 — 본 branch 존재 이유. 기대 동작: audience validator 로 401(D1).
|
||||
- **validator 미합성 → `aud` silent bypass (음성 테스트 필수, 가장 위험)**: §구현 가이드 §2 의 `DelegatingOAuth2TokenValidator` 합성을 틀리면 — audience validator 빈만 만들고 `JwtDecoder.setJwtValidator(...)` 등록을 누락하거나, default validator 를 덮어써 audience 를 미합성하는 경우 — `aud` 가 **조용히 무검사**로 통과한다. 위 "mapper 부재"의 loud 401 과 정반대로 **아무 에러 없이** cross-client 토큰이 통과해 "검증이 있다"는 착각을 남기는 가장 위험한 실패다. 기대 동작: 잘못된 `aud`(다른 client) 토큰이 **반드시 401** 임을 **음성 테스트**로 못박는다(형제 [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] §TODO 의 "잘못된 aud 토큰 → 401" 로컬 검증과 동일). Trace: §2 `UNSUPPORTED_IMPL_DECISION`(wiring 인용 범위 밖) + D1.
|
||||
- **발급 측 mapper 부재 → 정상 토큰도 거부**: Keycloak 이 `aud` 에 backend client_id 를 안 넣으면(기본 `aud=account`) audience validator 가 **정상 사용자 토큰도 401**. 함정: 검증 코드가 맞아도 발급 설정이 빠지면 전 사용자 로그인 실패. 기대 동작: SPA client scope 에 Audience mapper 선행(D4). Audience mapper 의 실제 realm/client-scope 프로비저닝은 [[raw/branch-notes/feature-keycloak-realm-client-export]](realm export) 소관 — 본 노트는 요건(D4)만 owner.
|
||||
- **JWKS 미가용 / kid 회전 mismatch**: Keycloak 키 회전 시 백엔드 캐시된 key 로 signature 검증 실패 → 새 kid 로 JWKS 재fetch 기대. 그러나 **cache TTL·자동 refetch 동작은 미검증**(D5 `UNSUPPORTED`). 기대 동작: 재fetch 로 자동 복구(가정), §Claims To Verify 로 확인.
|
||||
- **clock skew 경계**: iat/exp 경계에서 발급자·검증자 시계 편차로 갓 발급된 토큰이 `nbf`/`exp` 에 걸릴 수 있음. 기대 동작: 기본 skew 허용 — 단 **기본값 수치 미검증**(D6). 편차 큰 환경은 `JwtTimestampValidator` override.
|
||||
- **다중 audience 토큰**: `aud` 가 배열이고 backend client_id 를 **포함**하면 통과(`contains`). Boot property 방식과 custom `contains` 방식의 동작 차이(단일 vs 부분집합)는 §Claims To Verify 로 대조.
|
||||
- **issuer-uri startup unreachable**: 백엔드 기동 시 Keycloak 미가용이면 discovery 실패로 **startup 실패**(`SSRS-JWT-C2` 는 첫 요청 시 discovery). 완화: `jwk-set-uri` 병기로 AS ping 회피(`SSRS-JWT-C5`) 또는 docker-compose `depends_on: healthy`(형제 role-mapping §마주친 문제가 지적). `UNSUPPORTED_IMPL_DECISION` — 완화책 선택 기준은 배포 branch 소관.
|
||||
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-keycloak-internal-spa-direct-no-google]] (부모 hub, AP1) **D3** — 본 노트가 그 요약의 **정본 owner**. hub 의 §신뢰 경계 체크리스트 "`aud` 검증"·§토큰 교환 sequence step 7 이 본 노트 결정을 consume. 본 노트 D1/D4 가 바뀌면 hub 갱신 필요(비차단 전파).
|
||||
- [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]] D4~D6 — 본 D1/D6 위에 얹히는 deferred RBAC consumer. RS/audience detail은 그 문서가 소유하지 않는다.
|
||||
- [[raw/project-notes/keycloak-patterns-overview]] §5 고정 결정 **F3**(단일 realm + 패턴당 client 1개) — D1 의 audience 검증이 client 를 구분하는 **전제**. client 를 분리하지 않으면 `aud` 로 client 를 구별할 수 없어 audience 검증이 무의미.
|
||||
- [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] **D1** — 본 노트 D6 의 `iss` 검증이 성립하려면 Keycloak `KC_HOSTNAME` 고정으로 token `iss` 가 백엔드 `issuer-uri` 와 byte-level 일치해야 함. issuer 불일치 함정의 재현·해결은 그 branch 소관(single-EC2 맥락, 동일 원리).
|
||||
- [[raw/branch-notes/feature-keycloak-pkce-flow-stages]] · [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] — 같은 AP1 그룹. 토큰이 **어떻게 발급·저장**되는지 전제이며 본 노트는 발급된 토큰의 **검증 측**만. 계약 의존은 약함(경계 구분 유지).
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| Boot `audiences: backend-client-id` baseline이 정상 token을 허용하고 wrong-audience token을 거부하는지 | 방식 선택은 D1에서 종결됐지만 runtime repo가 없음 | 정상 `aud`→200, 다른 client `aud`→401을 E2E 확인; 복합 조건이 생길 때만 custom 방식 비교 | `planned` |
|
||||
| Keycloak SPA client 의 Client Scopes → Audience mapper 등록이 backend client_id 를 `aud` claim 에 정확히 포함시키는지 | 본 branch 본문 D4 - Keycloak 운영 경험 진술, verbatim Source 부재 | Keycloak admin console 에서 audience mapper 추가 → SPA 로그인 후 token decode 로 `aud` claim 에 backend client_id 포함 확인 | `planned` |
|
||||
| `clock skew 60s` 가 Spring Security 6.x default 인지 + 어떤 property 로 override 가능한지 | 본 branch 본문 진술 — D6 의 verbatim Source 부재 | Spring Security `JwtTimestampValidator` source code 또는 `JwtValidators` factory method 의 default 값 확인 + reference doc 정확 quote 추출 | `needs-confirmation` |
|
||||
| Spring Security `NimbusJwtDecoder` JWKS cache 기본 TTL 이 5분인지 + `kid` mismatch 시 자동 JWKS refetch 동작 | D5 가 UNSUPPORTED — verbatim Source 부재 | reference doc §Customizing the JwtDecoder 또는 `NimbusJwtDecoder.cache(...)` API doc 확인 | `needs-confirmation` |
|
||||
| `azp` (authorized party) claim 검증 추가가 single-client 강제 시 실제 필요한지 + Keycloak 이 `azp` 를 발급 token 에 포함시키는지 | 본 branch 본문 검증 항목 매트릭스의 "선택" 항목 — Source 부재 | OIDC Core §2 ID token 의 `azp` 정의 정독 + Keycloak 발급 token 의 `azp` claim 실제 존재 확인 | `planned` |
|
||||
| `JwtIssuerAuthenticationManagerResolver.fromTrustedIssuers(...)` 가 multi-realm 시나리오에서 각 issuer 마다 JwtDecoder 를 별도 캐싱하는지 | 본 branch 본문 진술 — 본 branch raw 발췌 (SSRS-JWT-C1~C6) 에 미포함 | Spring Security reference doc 의 multi-tenancy 섹션 정독 후 새 Claim 인용 추가 | `planned` |
|
||||
| Keycloak realm role (`realm_access.roles` / `resource_access.<client>.roles`) 가 `JwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(...)` 로 추출 가능한지 | `SSRS-JWT-C4` 의 Does-not-prove 가 customize 필요 명시 — 정확한 claim name 미확정 | 형제 branch `feature-keycloak-spring-rs-role-mapping` 의 결정과 결합, 실제 token 의 `realm_access.roles` 구조 확인 후 converter 동작 검증 | `planned` |
|
||||
| OAuth 2.1 draft 의 access token audience binding 직접 권고 quote 가 어느 § 에 위치 | D7 부분 corroborate — OA21-C3 는 refresh token 만 | OAuth 2.1 draft 전체 정독 → access token audience binding § 확인 후 Claim ID 추가 (OA21-C7 등) | `planned` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 이슈 1: Keycloak에서 SPA client가 받는 토큰의 `aud` claim에 backend client_id가 안 들어감.
|
||||
- 원인: Keycloak 기본 동작은 client_id를 자동으로 `aud`에 포함하지 않음. SPA client의 client scope에 audience mapper를 등록해야 함.
|
||||
- 시도: (구현 없음)
|
||||
- 해결: SPA client → Client Scopes → Add → Audience mapper (Included Client Audience = backend-client-id) — `documented-only`
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/keycloak-securing-apps-overview-official]]
|
||||
- [[raw/official-docs/spring-security-resource-server-jwt]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
<!-- GENERATED: branches:start -->
|
||||
- [[raw/branch-notes/feature-keycloak-spring-rs-role-mapping]]
|
||||
<!-- GENERATED: branches:end -->
|
||||
|
||||
> 본 sub-sub-branch 는 leaf — 자식 자료 없음. Phase 3 P3A 실 구현 또는 외부 산출물 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 오류 기록 (이 sub-sub-branch 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 documented-only 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — Phase 3 실 구현 단계에 누적)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 머지/종료 시점에 채움. `/ingest`가 이 섹션을 기준으로 wiki/projects/에 추출.
|
||||
|
||||
- PR 링크: (미구현 — 문서까지만)
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: 없음 (P2A는 `documented-only` 범위)
|
||||
- **wiki 추출 대상**: 현 단계 없음. 추후 P3A 구현 후 `wiki/concepts/spring-security-jwt-validation.md`로 합성 검토 가능.
|
||||
- **추출하지 않을 항목**: P2A 구현 없음. `documented-only` 유지.
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
---
|
||||
title: branch / feature-keycloak-spring-rs-role-mapping (Keycloak role → Spring RBAC mapping)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-CHILD-783CA54B
|
||||
kind: branch-child
|
||||
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-role-mapping
|
||||
parent_branch: feature-keycloak-spring-rs-audience-validator
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, p3a, implementation, spring-boot, resource-server, jwt, authorization]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: dd660a8ddd1df3dabc7775e90818479fd5796ee4072d707242dc10c70827c701
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-spring-rs-role-mapping (Keycloak role → Spring RBAC mapping)
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]]의 WI004 child branch.
|
||||
> **P3A는 실 구현 대상**. 본 sub-sub는 학습 + 작업 plan 기록 — 실 구현은 `/home/donghyeon/workspace/keycloak-patterns/`.
|
||||
|
||||
> **정합 노트 (2026-07-14 감사)**: 본 노트의 Spring RS 공통 셋업 + `aud` 검증 내용은 형제 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 가 **owner** (중복 정리). 본 노트의 고유 책임 = **role → `@PreAuthorize` (RBAC 인가)** 이며, 이는 §5 **deferred authZ 트랙**이다(4 패턴 authN E2E 이후 착수). hub 분류: FOLD-IN(→ audience-validator 근거) + RBAC 부분 DEFERRED.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: foreign audience token 수용 실패를 재현하고 validator 적용 후 401을 검증한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-SPA-CLIENT-001@1` | AP1은 public client와 Authorization Code + PKCE를 사용한다 | AP1 Resource Server의 role claim 변환과 authorization에 적용한다 | [[raw/project-notes/keycloak-patterns-overview]] |
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1` | done-bar는 E2E success와 signature security failure 재현·해결 evidence다 | audience validator parent의 security verification contract를 승계한다 | [[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 -->
|
||||
## 목표
|
||||
|
||||
검증을 통과한 Keycloak JWT의 `realm_access.roles`를 Spring `ROLE_*` authority로 변환하고 `/api/admin`에 RBAC를 강제한다. RS 공통 셋업과 audience 검증은 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1/D6을 선행 계약으로 소비한다.
|
||||
|
||||
면접 질문: "Keycloak이 발급한 JWT를 Spring에서 어떻게 검증하나요?"
|
||||
→ "토큰 검증은 audience owner 계약을 따르고, 이 branch에서는 `realm_access.roles`의 nested claim을 custom converter로 읽어 `ROLE_*` authority로 바꿉니다. `/api/admin`은 `admin-role`을 요구하고 prefix 중복을 음성 테스트합니다."
|
||||
|
||||
- 이슈:
|
||||
- PR: (별도 keycloak-patterns repo)
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- `JwtAuthenticationConverter` — Keycloak `realm_access.roles` → Spring `ROLE_*`
|
||||
- `/api/me` endpoint: `@AuthenticationPrincipal Jwt` → JWT claims 반환
|
||||
- `/api/admin` endpoint: `@PreAuthorize("hasRole('admin-role')")` 또는 SecurityFilterChain matcher
|
||||
- RBAC matcher/method-security 선택과 double-prefix 음성 테스트
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- Opaque token introspection (Keycloak access token은 JWT)
|
||||
- Custom JWT claim 변환 (예: `preferred_username` → `User` 도메인 객체 매핑)
|
||||
- Spring Session / 서버 측 세션
|
||||
- Method-level security 정밀 튜닝
|
||||
- Spring RS 의존성·`issuer-uri`·`JwtDecoder`·audience value/validator·CORS → [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1/D6. expected audience는 owner의 `backend-client-id`를 소비하며 여기서 재명세하지 않는다
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
- [[raw/official-docs/spring-security-resource-server-jwt]] — Spring Security Resource Server JWT 공식
|
||||
- [[raw/official-docs/spring-security-authorize-http-requests]] — RBAC enforcement location Alternative A (`authorizeHttpRequests` + `requestMatchers(...).hasRole(...)`) 공식 근거 — request-level 모델링, `AuthorizationFilter` timing, path-only matching 한계
|
||||
- [[raw/company-tech-blogs/keycloak-jwt-role-extraction-betweendata]] — `personal-blog`(Christian Huff). 커스텀 `Converter<Jwt, Collection<GrantedAuthority>>` 로 `realm_access` claim 을 읽어 `ROLE_` prefix 로 변환하고 `DelegatingJwtGrantedAuthoritiesConverter` 로 default scope converter 와 결합하는 구현 사례. `engineering-blog` 강도 — 공식 best practice 아님, D4 구현 detail 참고용
|
||||
- [[raw/official-docs/spring-security-method-security]] — RBAC enforcement location Alternative B(`@EnableMethodSecurity` + `@PreAuthorize("hasRole('admin-role')")`) 채택 근거 + unannotated method 미보호 CRITICAL backstop 경고(catch-all `HttpSecurity` 규칙 필수)
|
||||
- [[raw/official-docs/spring-security-nested-authorities-claim-issue-15201]] — `JwtGrantedAuthoritiesConverter.setAuthoritiesClaimName("realm_access.roles")` nested claim 미지원 known-limitation + custom converter 워크어라운드 + `ExpressionJwtGrantedAuthoritiesConverter`(6.4+) 공식 확인 (GitHub Issue #15201, vendor 저장소)
|
||||
- [[raw/official-docs/spring-security-authorization-defense-in-depth]] — RBAC enforcement location Alternative C(request-level + method-level 동시 사용 = defense in depth) 결정의 벤더 공식 근거
|
||||
- [[raw/official-docs/spring-security-authorization-architecture]] — `ROLE_` prefix 는 Spring Security 기본값(role-based rule 이 `ROLE_` 자동 부착, `SS-AUTHZ-ARCH-C5`) — `hasRole("admin-role")` double-prefix 계약(§구현 가이드 3)의 공식 근거
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] 선행 계약 확인: [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1/D6이 정상·wrong-audience E2E를 통과 — 등급: `planned`
|
||||
- [ ] `JwtAuthenticationConverter` 빈: `realm_access.roles` → `SimpleGrantedAuthority("ROLE_" + role)` 매핑 — 등급: `planned`
|
||||
- [ ] `@RestController` `MeController`: `GET /api/me` → `@AuthenticationPrincipal Jwt jwt` → `Map.of("sub", jwt.getSubject(), "preferred_username", jwt.getClaim("preferred_username"), "roles", jwt.getClaim("realm_access"))` 반환 — 등급: `planned`
|
||||
- [ ] `@RestController` `AdminController`: `GET /api/admin` — `@PreAuthorize("hasRole('admin-role')")` 또는 matcher 기반 — 등급: `planned`
|
||||
- [ ] Dockerfile (multi-stage: gradle build → JRE 21 runtime) — 등급: `planned`
|
||||
- [ ] 로컬 검증: regular-user 토큰으로 `/api/me` 200, `/api/admin` 403 — 등급: `planned`
|
||||
- [ ] 로컬 검증: admin-user 토큰으로 `/api/admin` 200 — 등급: `planned`
|
||||
- [ ] 선행 owner의 wrong-audience 401 결과를 consume하고 본 branch에서는 RBAC 200/403만 추가 검증 — 등급: `planned`
|
||||
- [ ] 로컬 검증: `hasRole("admin-role")`(prefix 자동) vs `hasRole("ROLE_admin-role")`(double-prefix 버그) 대조 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- **RS/audience prerequisite**: [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1/D4/D6이 owner다. 본 branch는 검증을 통과한 JWT만 입력으로 받는다.
|
||||
- **role mapping**: Keycloak token claim 구조 — `realm_access: { roles: [admin-role, user-role] }`. resource_access는 client별 role (out of scope).
|
||||
- **`/api/me` 응답에 raw JWT claims 노출 신중**: 학습 목적이라 OK, prod에서는 필요한 claim만 반환.
|
||||
- **Spring Boot 3 + Spring Security 6** 기준 lambda DSL 사용. 옛 fluent API는 deprecated.
|
||||
- **(2026-07-18 자동조사) `setAuthoritiesClaimName("realm_access.roles")` 는 nested 미지원**: 공식 확인된 사실 = nested `realm_access.roles` 는 이 API 로 못 읽고 custom `Converter` 또는 SS ≥6.4 의 `ExpressionJwtGrantedAuthoritiesConverter` 로만 처리(`SS-15201-C2`/`C3`). *왜* 실패하는지의 내부 원리("dot 을 경로 구분자로 안 쓰고 top-level claim 을 literal lookup")는 **추정** — SS-15201 는 이를 증명하지 않으며 소스/Javadoc 별도 확인 필요. 관측 결과는 **0 authority(silent 403)** 로 예상. §Decision Evidence Map D6 + §구현 가이드 1 참조.
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- 2026-07-18 (delegated): RS 셋업·audience 검증은 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1/D6을 따른다.
|
||||
- 2026-05-25: **realm-global 권한만 필요한 baseline에서는 realm role만 매핑**한다. client-specific 권한 namespace가 필요하면 `resource_access.<client>.roles` variant를 별도 결정한다. client 개수 자체는 선택 근거가 아니다.
|
||||
- 2026-05-25: **`@PreAuthorize` 대신 SecurityFilterChain matcher 우선.** 이유: 권한 정책 한 곳 집중 → 면접 답변 일관성.
|
||||
- 2026-07-18: **realm role → authority 매핑에 custom `Converter<Jwt, Collection<GrantedAuthority>>` 채택 (`setAuthoritiesClaimName` 폐기).** 이유: `setAuthoritiesClaimName` 은 nested `realm_access.roles` 를 파싱 못함(literal top-level lookup, silent 403). 대안: Boot ≥3.4 로 pin 시 `ExpressionJwtGrantedAuthoritiesConverter` + SpEL 한 줄. 근거: SS-15201, SSRS-JWT-C4, betweendata 사례. (자동조사 `/branch-spec`)
|
||||
- 2026-07-18: **D5(RBAC 강제 지점)의 `UNSUPPORTED_DECISION` 해소 — 근거 확보.** 기본 A(HTTP matcher), 조건부 B(`@PreAuthorize`+catch-all)/C(defense-in-depth). 근거: 공식 authorize-http-requests / method-security / features-authorization. (자동조사 `/branch-spec`)
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. `Decision ID` 는 본 branch-note 안에서 안정적으로 유지.
|
||||
> `선택 조건` 열: "이 조건일 때 이 결정, 다른 조건이면 어떤 대안". 분기 없으면 `N/A`.
|
||||
> **정합 (2026-07-14)**: RS-common(D1·D2·D3)은 형제 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 가 owner — 본 노트 in-scope 는 role→RBAC(D4·D5·D6). 상세는 §Audit & Findings.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | **DELEGATED** — [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D6의 RS 공통 셋업을 consume | RBAC는 검증 완료 JWT 위에 얹힘 | owner D6 | `delegated` | 본 branch에서 버전·decoder wiring을 재명세하지 않음 |
|
||||
| D2 | **DELEGATED** — [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1/D4의 audience 계약(`backend-client-id`)을 consume | expected audience 변경은 owner에서만 | owner D1/D4 | `delegated` | 본 branch는 audience 구현·테스트를 복제하지 않음 |
|
||||
| D3 | **DELEGATED** — [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D6의 bearer RS 실행 계약을 consume | RBAC 입력 전제 | owner D6 | `delegated` | session/CORS 세부를 재명세하지 않음 |
|
||||
| D4 | realm-global 권한이면 `realm_access.roles`만 매핑; client-specific 권한이 필요하면 `resource_access.<client>.roles` variant | 선택 기준은 **권한 namespace**다. client 수가 많아도 공통 권한이면 realm role을 유지할 수 있고, client별 격리가 필요하면 client role을 추가한다 | `raw/official-docs/spring-security-resource-server-jwt.md#SSRS-JWT-C4` | `official-vendor-doc + project policy` | 현재 authZ는 deferred. 실제 client별 권한 요구를 확정하기 전 realm-only를 외부 경험으로 승격 금지 |
|
||||
| D5 | RBAC 강제 지점 — 기본 `SecurityFilterChain` matcher(A), 조건부 B/C | 기본=A(`authorizeHttpRequests` matcher): endpoint 소수 + role↔URL 안정 + "정책 한 곳 집중/면접 일관성" 목표. B(`@PreAuthorize`, **A catch-all 유지 필수**): 파라미터/소유권 기반 판단 또는 비-HTTP 진입점. C(A+B 병행=defense-in-depth): 프로덕션 노출 + matcher/annotation 누락이 실제 위협일 때 | `raw/official-docs/spring-security-authorize-http-requests.md#SS-AUTHZ-HTTP-C1` (request-level 모델링 — `/admin` 하위 authority), `raw/official-docs/spring-security-authorize-http-requests.md#SS-AUTHZ-HTTP-C3` (AuthorizationFilter 가 DispatcherServlet/컨트롤러 실행 전 차단), `raw/official-docs/spring-security-method-security.md#SPRING-MS-C5` (unannotated method 미보호 → B 시 catch-all 필수), `raw/official-docs/spring-security-authorization-defense-in-depth.md#SS-AUTHZ-DID-C1` (request+method = defense in depth) | `official-vendor-doc` (+ personal/company-blog corroborate: Okta·Marco Behler·howtodoinjava — 미아카이브, official 로 충분) | C 채택 시 두 계층 role 조건 동기화 미스매치가 "단일 설명 위치" 목표 훼손; A 단독 시 URL glob drift(SS-AUTHZ-HTTP-C4 path-only); B 단독 시 미어노테이트/self-invocation 무보호 |
|
||||
| D6 | Keycloak `realm_access.roles` → `GrantedAuthority` 매핑 메커니즘 = 수동 custom `Converter<Jwt, Collection<GrantedAuthority>>` (`setAuthoritiesClaimName` 폐기) | nested claim(`realm_access.roles`)이라 flat-claim 전용 `setAuthoritiesClaimName` 는 확정 실패(nested 미지원 = SS-15201 공식; 내부 lookup 원리는 추정 → §진행 중 메모). 대안: Boot ≥3.4 / SS ≥6.4 로 pin 가능하면 `ExpressionJwtGrantedAuthoritiesConverter` + SpEL `[realm_access][roles]` (커스텀 클래스 없이 한 줄) | `raw/official-docs/spring-security-nested-authorities-claim-issue-15201.md#SS-15201-C2` (custom `JwtGrantedAuthoritiesConverter` 구현이 nested role 추출에 필요), `raw/official-docs/spring-security-nested-authorities-claim-issue-15201.md#SS-15201-C3` (`ExpressionJwtGrantedAuthoritiesConverter` fix, milestone 6.4.0), `raw/official-docs/spring-security-resource-server-jwt.md#SSRS-JWT-C4` (default 는 `scope`/`scp` 만 매핑), `raw/company-tech-blogs/keycloak-jwt-role-extraction-betweendata.md#KC-ROLE-BD-C1` (custom `Converter` 로 `realm_access` 읽는 구현 예) | `official-vendor-doc + engineering-blog` | `jwt.getClaim()` 이 claim 부재 시 빈 컬렉션 반환(silent 403)은 소스 self-grep 전까지 `needs-confirmation`; betweendata 예제는 `ROLE_realm_` prefix + resource role 도 매핑(D4 범위 밖) → 본 브랜치는 `ROLE_` + realm-only 로 조정 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 본 §는 이 branch 의 **in-scope = RBAC/role 매핑 트랙(D4·D5·D6)** 만 구체화한다. RS 공통 셋업(Boot 의존성·`issuer-uri`·`JwtDecoder`·`aud` 검증 = D1·D2·D3)은 **형제 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 가 owner**(2026-07-14 감사) → 여기서 재명세하지 않고 §엣지·실패·의존 "다른 계약 의존" 으로 링크(R3 OUT_OF_BRANCH_SCOPE).
|
||||
> `keycloak-patterns` repo 부재(NO_GROUND_TRUTH, §Audit) → 아래 전부 `planned`. 실 구현 후 코드 grep 으로 등급 승급.
|
||||
|
||||
### 1. Realm role → GrantedAuthority 매핑 (핵심)
|
||||
|
||||
> **Trace**: D4(realm-only) + D6(custom Converter 메커니즘) — `SS-15201-C2`/`SS-15201-C3`, `SSRS-JWT-C4`, `KC-ROLE-BD-C1`.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (1) authority prefix 문자열 = `ROLE_` (betweendata 사례는 `ROLE_realm_`) — `hasRole("admin-role")` 이 `ROLE_admin-role` 을 기대하므로 `ROLE_` 채택. trade-off: betweendata 예제와 불일치하나 표준 `hasRole` 계약에 정합(§3). (2) claim 부재 시 빈 컬렉션 반환(null-safe) — 근거 raw 는 방어 코드 형태를 규정 안 함, silent-403 진단성 위한 임의 선택.
|
||||
|
||||
| 항목 | 명세 |
|
||||
|---|---|
|
||||
| 클래스 | `KeycloakRealmRoleConverter implements Converter<Jwt, Collection<GrantedAuthority>>` (별도 파일 또는 `SecurityConfig` static nested class) |
|
||||
| 읽기 | `Map<String,Object> realmAccess = jwt.getClaimAsMap("realm_access");` → `realmAccess.get("roles")` 를 `Collection<String>` 으로 |
|
||||
| 방출 | 각 role → `new SimpleGrantedAuthority("ROLE_" + role)` |
|
||||
| null-safety | `realmAccess == null` 또는 `roles` 가 `Collection` 아니면 → `Collections.emptyList()` |
|
||||
| wiring | `JwtAuthenticationConverter jac = new JwtAuthenticationConverter(); jac.setJwtGrantedAuthoritiesConverter(new KeycloakRealmRoleConverter());` → `.oauth2ResourceServer(o -> o.jwt(j -> j.jwtAuthenticationConverter(jac)))` |
|
||||
| 대안(버전 pin 시) | Boot ≥3.4 / SS ≥6.4 → 커스텀 클래스 대신 `ExpressionJwtGrantedAuthoritiesConverter` + SpEL `"[realm_access][roles]"` (`SS-15201-C3`) — 단 본 브랜치는 버전 미pin 이라 custom Converter 를 기본으로 함 |
|
||||
|
||||
### 2. `/api/admin` RBAC 강제 지점 (기본 A)
|
||||
|
||||
> **Trace**: D5 — `SS-AUTHZ-HTTP-C1`/`SS-AUTHZ-HTTP-C3`, `SPRING-MS-C5`(backstop), `SS-AUTHZ-DID-C1`(조건부 C).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: URL glob `/api/admin/**` + role 명 `admin-role` — 공식 예시는 illustrative(`SS-AUTHZ-HTTP-C1` "Does not prove admin-role name"); glob/명명은 프로젝트 임의 결정(Keycloak realm role 명명은 `feature-keycloak-realm-client-export` 소관).
|
||||
|
||||
| 항목 | 명세 |
|
||||
|---|---|
|
||||
| 강제(A) | `.authorizeHttpRequests(a -> a.requestMatchers("/api/admin/**").hasRole("admin-role").anyRequest().authenticated())` |
|
||||
| `/api/me` | 별도 role 없이 `authenticated()` (위 `anyRequest()` 로 커버) |
|
||||
| 타이밍 | `AuthorizationFilter` 가 `DispatcherServlet` 이전 실행 → 컨트롤러 도달 전 차단(`SS-AUTHZ-HTTP-C3`) |
|
||||
| 조건부 승격(B) | 파라미터/소유권 기반 필요 시 `@EnableMethodSecurity` + `@PreAuthorize("hasRole('admin-role')")`, **단 `anyRequest().authenticated()` catch-all 유지 필수**(`SPRING-MS-C5` — unannotated method 무보호 방지) |
|
||||
| 조건부 승격(C) | 프로덕션 노출 시 A+B 병행(defense in depth, `SS-AUTHZ-DID-C1`) — 두 계층 role 조건 동기화 규율 전제 |
|
||||
|
||||
### 3. `hasRole` prefix 계약 (double-prefix 함정)
|
||||
|
||||
> **Trace**: D6 — `SS-AUTHZ-ARCH-C5`(`ROLE_` 자동 prefix = Spring Security 기본값, 공식), `KC-ROLE-BD-C2`(double-prefix 위험 사례).
|
||||
|
||||
`hasRole("admin-role")` 은 내부적으로 `ROLE_` 를 자동 prefix (`SS-AUTHZ-ARCH-C5`) → §1 converter 가 이미 `ROLE_admin-role` 을 만들었으므로 인자는 prefix 없이 `hasRole("admin-role")` 로 호출한다. `hasRole("ROLE_admin-role")` 로 부르면 `ROLE_ROLE_admin-role` 을 조회 → admin 이 항상 403. §Claims To Verify + TODO 에 이 self-check(prefix 유무 대조) 추가.
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4(깊이 게이트) 캡처용. 정상 경로 외 실패/엣지/다른 계약 의존을 미리 열거.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **nested claim silent failure** — `setAuthoritiesClaimName("realm_access.roles")` 사용 시 literal top-level lookup 실패 → 0 authority → 모든 `hasRole` false → 전 요청 403, 예외/로그 없음(`SS-15201-C2`). 기대 동작: custom converter(§1)로 회피 + `/api/me` 응답에 `ROLE_user-role` 존재 확인.
|
||||
- **`realm_access` claim 부재** — Keycloak client 에 realm-role mapper 없으면 claim 누락 → converter empty → 403. 기대: null-safe converter(§1) + realm role mapper 설정(→ 아래 의존).
|
||||
- **double-prefix** — `hasRole("ROLE_admin-role")` 오용 시 `ROLE_ROLE_admin-role` → admin 항상 403(§3).
|
||||
- **unannotated-method gap** (조건부 B 채택 시) — 어노테이션 누락 endpoint 무보호. catch-all `anyRequest().authenticated()` 유지로 방어(`SPRING-MS-C5`).
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1/D6 — RS 공통 셋업과 `backend-client-id` audience 검증 owner. 본 role 매핑은 검증 완료 JWT 위에 얹힌다.
|
||||
- [[raw/branch-notes/feature-keycloak-realm-client-export]] `#D5` — realm-export.json 이 realm role(`admin-role`/`user-role`) 정의 + user `realmRoles` 부여를 담음(그 노트 §TODO Role 생성). Keycloak 기본 realm-roles protocol mapper 가 이를 token 의 `realm_access.roles` 로 실음 → export 가 role 을 안 담으면 본 매핑은 빈 authority(위 "claim 부재" 엣지).
|
||||
- [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] `#D1` — `KC_HOSTNAME`/`iss` 문자열 일치(token 이 통과해야 role 매핑 단계에 도달).
|
||||
|
||||
## Audit & Findings
|
||||
|
||||
> §2 ground-truth 확인 + 결정 정합 감사 결과. 자동 rewrite 대상 아님(surface + 정합 권고).
|
||||
|
||||
- **BROKEN_CODE_DRIFT** (surface-only, read-only 권고): 인용 근거 [[raw/official-docs/spring-security-resource-server-jwt]] 의 §"권한 추출 customize (해석)" 코드가 `setAuthoritiesClaimName("realm_access.roles")` 를 사용 — nested claim 을 파싱하지 못해 **작동하지 않는 패턴**(`SS-15201-C2`). 그 raw 는 이미 "추가 확인 필요" 로 flag 되어 있으나, 코드 블록 자체에 "nested 미지원 → custom Converter / `ExpressionJwtGrantedAuthoritiesConverter`(6.4+) 필요" caveat 추가를 권고. 해당 raw 는 별도 소유 → 자동 수정 안 함(정합 권고만).
|
||||
- **DELEGATION** (2026-07-14 감사 정합): RS 공통 셋업 + `aud`(D1·D2·D3)는 형제 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 가 owner. 본 노트 in-scope = **role→RBAC(D4·D5·D6)** = deferred authZ 트랙([[raw/project-notes/keycloak-patterns-overview]] §Deferred — 4 패턴 authN E2E 후 착수). D1·D3 가 여기서 `UNSUPPORTED_DECISION` 인 것은 RS-common(sibling 소유 rationale)이기 때문 — 본 브랜치 추가 조사 대상 아님(R3 OUT_OF_BRANCH_SCOPE).
|
||||
- **NO_GROUND_TRUTH**: 실 구현 대상 repo `/home/donghyeon/workspace/keycloak-patterns/` 부재(2026-07-18 확인) → 본 노트 모든 항목 `planned`/`documented-only`. `actually-implemented` 주장은 코드 대조 불가이므로 하지 않음(§구현 가이드는 사전 명세일 뿐).
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서나 사례는 근거지만, 내 프로젝트에서의 동작을 자동으로 보장하지 않는다.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| RS/audience prerequisite가 완료된 JWT만 RBAC converter에 도달 | 선행 owner repo가 아직 미구현 | [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1/D6 E2E 결과를 consume하고 본 branch 테스트 fixture의 전제로 기록 | `planned (delegated)` |
|
||||
| custom `KeycloakRealmRoleConverter`(§1)가 실제 Keycloak token 에서 `ROLE_user-role`/`ROLE_admin-role` authority 를 방출 (`setAuthoritiesClaimName` 은 D6/SS-15201 로 이미 폐기 확정) | 메커니즘은 확정됐으나 로컬 실동작 + 실제 token 의 `realm_access.roles` 구조/composite role 확장 여부 미확인 | regular-user 로 token 발급 → backend `/api/me` 응답에서 `ROLE_user-role` granted authority 존재 확인 | `planned` |
|
||||
| `@PreAuthorize("hasRole('admin-role')")` / `.hasRole("admin-role")` 가 converter 의 `ROLE_admin-role` 과 정확히 매칭(double-prefix 없음) | `hasRole` 이 `ROLE_` 를 자동 prefix — converter 도 `ROLE_` 를 붙이므로 인자에 `ROLE_` 재기입 시 `ROLE_ROLE_` 버그(`KC-ROLE-BD-C2`) | admin-user token 으로 `/api/admin` 200 확인 → 인자를 `hasRole("ROLE_admin-role")` 로 바꿔 403 되는지 대조 | `planned` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- (선행 계약) audience 발급·검증 문제는 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1/D4에서 추적한다. 본 branch는 검증 완료 뒤의 RBAC만 소유한다.
|
||||
- (구현 시작 후 추가) `issuer-uri`가 backend 기동 시점에 reachable하지 않으면 Spring startup 실패 — docker-compose `depends_on healthy`로 해결.
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/company-tech-blogs/keycloak-jwt-role-extraction-betweendata]]
|
||||
- [[raw/official-docs/spring-security-authorization-defense-in-depth]]
|
||||
- [[raw/official-docs/spring-security-authorize-http-requests]]
|
||||
- [[raw/official-docs/spring-security-method-security]]
|
||||
- [[raw/official-docs/spring-security-nested-authorities-claim-issue-15201]]
|
||||
- [[raw/official-docs/spring-security-resource-server-jwt]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
> 본 sub-sub-branch 는 leaf — 자식 자료 없음. Phase 3 P3A 실 구현 또는 외부 산출물 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 오류 기록 (이 sub-sub-branch 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 documented-only 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — Phase 3 실 구현 단계에 누적)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 로컬 `curl` 검증 통과 시 `planned` → `actually-implemented`/`locally-verified` 승급.
|
||||
|
||||
- PR 링크: (별도 keycloak-patterns repo)
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: 로컬 docker-compose / 단일 EC2 시뮬레이션
|
||||
- **wiki 추출 대상**:
|
||||
- `actually-implemented` 항목: (구현 후 채움)
|
||||
- `locally-verified` 항목: (구현 후 채움)
|
||||
- `prod-verified` 항목: (없음)
|
||||
- **추출하지 않을 항목**: 현재 전부 `planned`.
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
---
|
||||
title: branch / feature-keycloak-three-leg-trust-chain (3-leg trust chain — Browser ↔ Keycloak ↔ Google 검증 메커니즘)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-CHILD-11A28CFF
|
||||
kind: branch-child
|
||||
project: keycloak-patterns-overview
|
||||
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-015
|
||||
inherits: [DEC-KEYCLOAK-PATTERNS-OVERVIEW-IDP-BROKERING-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-KEYCLOAK-PATTERNS-OVERVIEW-003]
|
||||
contract_packet: 1
|
||||
branch: feature-keycloak-three-leg-trust-chain
|
||||
parent_branch: feature-keycloak-idp-brokering-google-client
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, spa, idp-brokering, google-federation, trust-chain, jwt, p2b]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 1af4709799789103babb4b3503ffefc67f35e51a4eb8c5175c4928267a419143
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-three-leg-trust-chain (3-leg trust chain 검증)
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/branch-notes/feature-keycloak-idp-brokering-google-client]]의 WI015 child branch.
|
||||
> 학습 노트. P2B는 `documented-only` 단계.
|
||||
|
||||
> **본 노트의 역할 (2026-07-18 `/branch-spec` 정리)**: 본 노트는 부모 P2B([[raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation]]) 가 **hop 별 검증 매트릭스의 owner 로 위임한** 결정(D5)의 정본이다(부모 §신뢰 경계 delegation). 부모는 이 D5 를 consume 만 하며, 본 노트가 각 hop 의 *누가·무엇을·어떻게 검증하는가* 를 소유한다.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/branch-notes/feature-keycloak-idp-brokering-google-client]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: Google login·Keycloak user mapping 200과 application diff 0줄이 검증된다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-IDP-BROKERING-001@1` | Google federation은 Keycloak IdP brokering과 hardened First Broker Login을 사용한다 | Browser·Keycloak·Google의 hop별 trust verification에 적용한다 | [[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 -->
|
||||
## 목표
|
||||
|
||||
P2A는 2-leg trust (Browser ↔ Keycloak)였으나, P2B는 **3-leg trust** (Browser ↔ Keycloak ↔ Google). 각 hop마다 **누가 무엇을 검증하는지** 명확히 정리.
|
||||
|
||||
면접 질문: "Google 로그인이 추가되면 신뢰 검증이 어떻게 늘어나나요?"
|
||||
→ "OIDC 표준상 RP는 Google ID token의 signature·issuer·audience·expiry와 요청에 보낸 `nonce` 일치를 검증해야 합니다. 이 배치에서는 Keycloak이 RP 역할을 맡지만, target Keycloak 버전이 nonce를 자동 송신·대조하는 제품 동작은 아직 wire trace로 확인하지 않았습니다. 이후 Keycloak이 자체 서명한 access token을 발급하고 backend는 Keycloak issuer/JWKS만 신뢰합니다."
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- **Hop 1: Google → Keycloak** — Google ID token signature 검증
|
||||
- **Hop 2: Keycloak → SPA** — Keycloak access token signature 발급
|
||||
- **Hop 3: SPA → Backend** — backend의 Keycloak JWT 검증
|
||||
- Issuer 검증 규칙:
|
||||
- Google `iss=https://accounts.google.com` — **정확히 이 문자열**. (⚠️ **정정 2026-07-18**: 원래 여기 "또는 `accounts.google.com` — spec 상 둘 다 허용" 이라 적었으나 **사실과 다르다**. 아카이브 근거 `raw/official-docs/google-oidc-discovery-spec.md#GOOGLE-OIDC-C1` 의 discovery JSON 은 `iss` 를 정확히 `https://accounts.google.com` 로만 규정하고, 그 "Does not prove" 열이 bare-hostname alias 를 명시적으로 부인한다. §Claims To Verify CV1 참조.)
|
||||
- Keycloak `iss=https://kc.example.com/realms/{realm-name}`
|
||||
- JWKS rotation 정책 비교 (Google vs Keycloak)
|
||||
- Keycloak이 Google jwks_uri를 캐시하는 방식
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 코드 변경 없음 검증 — [[raw/branch-notes/feature-keycloak-federation-spa-zero-change]]
|
||||
- claim mapping — [[raw/branch-notes/feature-keycloak-idp-mappers-claim-to-role]]
|
||||
- Account Linking — [[raw/branch-notes/feature-keycloak-account-linking-spa-ux]]
|
||||
- **발급 측 `aud` 주입 + 백엔드 audience 검증 구현** — [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D1·D4 소유. 본 노트 Hop3-`aud` cell 은 그 계약을 consume 만.
|
||||
- **`iss` 문자열 byte-match 를 성립시키는 `KC_HOSTNAME` 고정** — [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] D1 소유. 본 노트 Hop3-`iss` cell 의 전제.
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
| Source | 정당화하는 결정 |
|
||||
|---|---|
|
||||
| [[raw/official-docs/keycloak-first-broker-login-flow]] | First Broker Login Flow 존재 + account linking 시점 (D4) |
|
||||
| [[raw/official-docs/spring-security-resource-server-jwt]] | 백엔드가 단일 issuer(Keycloak) JWKS 로 self-configure + `iss`/`aud` 검증 (D1, D5 Hop3, CV4) |
|
||||
| [[raw/official-docs/google-oidc-discovery-spec]] | Google issuer 문자열 + RS256 + nonce Required + local validation (D5 Hop1, D3, CV1) |
|
||||
| [[raw/official-docs/security-jwt-rfc-7519-validation]] | JWT `aud` MUST-reject / `exp` MUST-NOT-accept (D5 Hop1·Hop3 aud·exp) |
|
||||
| [[raw/official-docs/google-oauth2-redirect-uri-validation-official]] | redirect URI 정확 매칭 → `redirect_uri_mismatch` (CV7) |
|
||||
| [[raw/official-docs/openid-connect-core-id-token-validation]] | OIDC Core 1.0 §2/§3.1.2.1/§3.1.3.7 — ID Token `aud`=RP client_id + `nonce` 검증 mandatory (`OIDC-CORE-C1`~`C5`; D5 Hop1-aud·nonce, D3) |
|
||||
| [[raw/official-docs/jwks-keycloak-key-rotation-active-passive]] | Keycloak 이 자체 active key pair 로 새 서명 생성 (`KC-ROT-C1`) — **D5 Hop2(Keycloak→SPA 재서명) 근거로만**. ⚠️ 아래 disambiguation |
|
||||
|
||||
> **인용 범위 한정 (disambiguation)**: `raw/official-docs/jwks-keycloak-key-rotation-active-passive.md` (KC-ROT-C*) 는 **Keycloak 자신의 realm 서명키** active-passive 회전이다 — D5 **Hop2**(Keycloak 이 자체 키로 재서명)의 근거로는 유효하나, **외부 Google JWKS 캐시**(D2)와는 무관하다. D2 근거로 인용하면 파일명의 "JWKS" 에 낚인 `FILENAME_INFERENCE` 오용이다.
|
||||
|
||||
## TODO
|
||||
|
||||
- [x] 3-leg 다이어그램 작성 (각 hop의 검증 항목 표기) — 등급: `documented-only` (§구현 가이드 §1 매트릭스로 승격)
|
||||
- [x] Google ID token 검증 항목 정리: signature(RS256, JWKS), `iss`, `aud`, `exp`, `nonce` — 등급: `documented-only` (§구현 가이드 §1, cell 별 근거)
|
||||
- [x] Google `iss` 허용값 — **정정 완료**: `https://accounts.google.com` **단일** (bare-hostname alias 는 spec 부인). 근거 `#GOOGLE-OIDC-C1` — 등급: `documented-only` (CV1 resolved-as-contradiction)
|
||||
- [x] Keycloak access token 검증 항목 정리 (backend 측): signature, `iss`, `aud`, `exp` — 등급: `documented-only` (§구현 가이드 §1 Hop3, `SSRS-JWT-C1/C2/C6`). ※`azp`/`typ=Bearer` 는 본 노트 Sources 에 직접 인용 없음 → 형제 audience-validator 매트릭스 소관
|
||||
- [ ] Google JWKS rotation 빈도 (수일~수주 주기, 정확한 SLA 없음) — 등급: `needs-confirmation` (CV2, 아카이브 부재 — live header 관찰 필요)
|
||||
- [ ] Keycloak이 Google jwks_uri를 캐시하는 정책: 기본 캐시 TTL, expired key fallback — 등급: `needs-confirmation` (CV3/D2, 아카이브 부재 — Keycloak IdP config doc/소스 필요)
|
||||
- [ ] **함정**: Keycloak이 Google JWKS 캐시 갱신 실패 시 Google 로그인 전체 장애 → fallback 정책 확인 — 등급: `needs-confirmation` (§엣지·실패·의존)
|
||||
- [x] **함정**: backend가 Keycloak issuer를 잘못 적으면 모든 Google-originated token 거부 — 등급: `documented-only` (CV4 resolved, `SSRS-JWT-C6` — 단 local 재현은 `planned`)
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- 핵심 통찰: **backend는 Google을 모른다.** backend의 JWT validation 코드는 Keycloak issuer / Keycloak JWKS만 본다. Google 추가/제거는 backend 코드에 영향 없음.
|
||||
- **누가 무엇을 검증하나** 정리 (§구현 가이드 §1 로 명세 승격 — 아래는 원본 통찰 보존):
|
||||
|
||||
| Hop | 검증자 | 검증 대상 | 검증 항목 |
|
||||
|-----|--------|-----------|----------|
|
||||
| Google → Keycloak | Keycloak | Google ID token | signature(RS256, Google JWKS), `iss`, `aud=Keycloak이 보유한 Google client_id`, `exp`, `nonce` |
|
||||
| (Keycloak 내부) | Keycloak | First Broker Login Flow 정책 | `email_verified`, `hd`, Account Linking 결정 |
|
||||
| Keycloak → SPA | (Keycloak이 발급) | Keycloak access token | (Keycloak이 RS256 서명) |
|
||||
| SPA → Backend | Backend | Keycloak access token | signature(Keycloak JWKS), `iss=https://kc/realms/{r}`, `aud=<backend-client-id>`, `exp` |
|
||||
|
||||
- Google JWKS 키 갱신 빈도가 빠른 편. spec에는 정해진 SLA 없음. Keycloak이 캐시한 키가 만료되었을 때 자동 refetch 필요.
|
||||
- **redirect URI 변조 방지**: Google Cloud Console에 등록된 redirect URI 외 거부. Keycloak broker endpoint URL 변경 시 Google에도 반영 필요.
|
||||
- **`nonce` 검증 계약**: OIDC RP는 요청의 nonce와 ID token nonce를 대조해야 한다. Keycloak이 이를 자동 송신·대조하는 target-version 제품 동작은 `needs-confirmation`이며 HAR/source 확인 전 사실형으로 표현하지 않는다.
|
||||
- **2026-07-18 (`/branch-spec` 채움 pass)** — pre-template(2026-05-25) 노트를 템플릿 정합으로 보강: `선택 조건`(R2) 열 추가 · `## 구현 가이드`(§1 Hop별 검증 계약 · §2 issuer/audience 문자열 정합) · `## 엣지·실패·의존` 추가 · Sources 1→6 확장 · 섹션 순서 템플릿 정렬(Cluster 하단·Sources 상단). **NO_GROUND_TRUTH** — keycloak-patterns 는 실 구현 repo(`/home/donghyeon/workspace/keycloak-patterns/`, 부재 확인)가 없어 전 항목 `documented-only`/`needs-confirmation`(코드 grep 불가). ca-tmpl 은 별개 프로젝트지만 `APP_SECURITY_JWT_ISSUER` env-key + `AUTH_ISSUER_MISMATCH` 에러코드가 D1/CV4 의 "단일 issuer" 원리를 실물로 예시(cross-project 참고, 본 노트 등급엔 미반영).
|
||||
- **자동조사** — `wiki-research-lane` 로 기존 아카이브 8+5 문서를 재앵커링: D1 + D5 의 12 cell 중 8개가 official 근거 획득(이전엔 D4 외 전부 `UNSUPPORTED_DECISION`). 신규 조사 1건(OIDC Core spec 아카이브)으로 Hop1-`aud`/`nonce` cell 을 승격. deferred 3건(Keycloak 외부 JWKS 캐시·Google rotation cadence·Keycloak 기본 서명 알고리즘 — 아카이브 문서로 안 닫히는 runtime/config-empirical 항목, §Claims To Verify 로 위임).
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- 2026-05-25: **backend는 Keycloak JWKS만 신뢰** — Google JWKS는 backend 측에서 절대 검증하지 않음. 이유: 신뢰 경계 단순화. backend 입장에서 IdP는 Keycloak 하나.
|
||||
- 2026-05-25: **Google JWKS rotation은 Keycloak 책임** — Keycloak realm export / restore 시 캐시 초기화될 수 있음. 운영 시 모니터링 항목.
|
||||
- 2026-05-25 (정합 2026-07-18): **`nonce` 검증은 OIDC RP 의무**다. Keycloak 자동 처리 및 비활성화 옵션 존재 여부는 target version에서 미검증이다.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 본 branch 의 결정-근거 매핑. `선택 조건` 열(R2, 2026-07-18 `/branch-spec` 추가): "이 조건일 때 이 결정, 다른 조건이면 어떤 대안". 분기 없는 원리 진술은 `N/A`.
|
||||
> **2026-07-18 재앵커링**: 이전 판은 D4 외 전부 `UNSUPPORTED_DECISION` 이었다(당시 Source 가 `keycloak-first-broker-login-flow` 1개뿐). `wiki-research-lane` 가 기존 아카이브에서 D1·D5(8/12 cell)·D3(일부)의 직접 근거를 발굴 + OIDC Core 신규 아카이브로 Hop1-aud/nonce 를 닫아 재mapping 했다. D2 와 D5 의 4 cell 은 genuine gap 으로 남아 라벨 유지.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | backend 는 Keycloak JWKS 만 신뢰 (Google JWKS 는 backend 측에서 절대 검증하지 않음) | 백엔드가 JWT 를 **직접 검증**하는 Resource Server(P2B)면 단일 issuer(Keycloak)만 신뢰가 기본 — `issuer-uri` 한 줄이 정확히 1개 AS 로 self-configure. **대안**(백엔드가 Google JWKS 도 검증)은 신뢰 경계를 2개로 늘려 federation 추가/제거마다 백엔드 변경 유발 → 기각. 백엔드가 JWT 자체를 안 보는 구성이 필요하면 배치를 Edge forward-auth(P1B)로 바꿈(별도 패턴) | `raw/official-docs/spring-security-resource-server-jwt.md#SSRS-JWT-C1` (issuer-uri 로 self-configure + iss 검증), `raw/official-docs/spring-security-resource-server-jwt.md#SSRS-JWT-C2` (4단계 deterministic discovery — 단일 AS 의 jwks_url) | `official-vendor-doc` | multi-issuer 는 `JwtIssuerAuthenticationManagerResolver` 별도(범위 밖). "신뢰 경계 단순화" 는 이제 mechanism 근거 보유(이전 UNSUPPORTED 해소). cross-project 실물 예시: ca-tmpl `APP_SECURITY_JWT_ISSUER`(단일 issuer URI 필수) + `AUTH_ISSUER_MISMATCH` 에러코드 |
|
||||
| D2 | Google JWKS rotation(=외부 IdP JWKS 신선도)은 Keycloak 책임 (realm export/restore 시 캐시 초기화 가능, 운영 모니터링 항목) | Keycloak 이 broker 로서 Google id_token 을 검증하는 모든 P2B 구성 — **대안 없음**: 백엔드가 Google 을 안 보므로(D1 의 따름) Google JWKS 신선도는 구조상 Keycloak 만 담당 가능. 단 캐시 TTL/fallback *동작*은 미검증 | UNSUPPORTED_DECISION (Keycloak 의 외부 IdP JWKS 캐시 정책/TTL/fallback 은 아카이브 부재 — `wiki-research-lane` 가 `keycloak-identity-brokering-overview-official`·`keycloak-identity-broker-spi`·`keycloak-import-export-realms` 3개 추가 확인했으나 어느 것도 미기술. **`jwks-keycloak-key-rotation-active-passive.md` 는 Keycloak 자체 realm 키 회전이라 D2 근거 아님** — 위 disambiguation) | UNSUPPORTED_DECISION | 캐시 refetch 실패 시 Google 로그인 전체 5xx(§엣지·실패·의존). monitoring alarm 설계 전 Keycloak IdP config 페이지 또는 소스(`OIDCIdentityProvider`) 확인 의무 — §Claims To Verify CV3 |
|
||||
| D3 | OIDC RP는 nonce를 송신하고 ID Token의 nonce 일치를 검증해야 한다. target Keycloak의 자동 처리 여부는 별도 제품 검증 | Google brokering의 replay 방지 계약. 표준 의무와 특정 제품 동작을 분리한다 | `raw/official-docs/google-oidc-discovery-spec.md#GOOGLE-OIDC-C5`, `raw/official-docs/openid-connect-core-id-token-validation.md#OIDC-CORE-C2`, `#OIDC-CORE-C5` | `official-standard` (의무) / `needs-confirmation` (Keycloak 제품 동작) | Keycloak 자동 송신·대조와 설정 토글은 HAR/source 확인 전 외부 주장 금지 |
|
||||
| D4 | Keycloak 의 First Broker Login Flow 가 외부 IdP (Google) 로 첫 로그인 시 account linking 정책을 실행한다는 일반 진술 | N/A (원리 진술 — account linking 의 정확한 정책은 owner 형제 [[raw/branch-notes/feature-keycloak-first-broker-login-flow]] 소관) | `raw/official-docs/keycloak-first-broker-login-flow.md#KC-FBL-C1` (First login flow 존재 + sub-section 구조) | `official-vendor-doc` | KC-FBL-C2~C4 는 `needs-confirmation` — verbatim 재검증 보류. account linking 의 정확한 동작(자동 link vs prompt)은 owner 형제 소관 |
|
||||
| D5 | Hop 별 검증 매트릭스 (Hop1 Google→Keycloak / Hop2 Keycloak→SPA / Hop3 SPA→Backend) — **부모 P2B 가 위임한 owner 결정** | N/A (검증 계약 명세, 분기 아님). 단 각 hop 검증 *주체*는 배치 의존: P2B(SPA direct)면 Hop3 검증자=백엔드 Resource Server, P1B(edge proxy)면 oauth2-proxy 대행(형제 패턴) | **cell 별**(§구현 가이드 §1 표): Hop1-sig `#GOOGLE-OIDC-C3`·`#GOOGLE-OIDC-C8`; Hop1-iss `#GOOGLE-OIDC-C1`; Hop1-aud `security-jwt-rfc-7519-validation.md#JWT-RFC7519-C1`(generic) + `openid-connect-core-id-token-validation.md#OIDC-CORE-C1`/`#OIDC-CORE-C4`; Hop1-exp `#JWT-RFC7519-C2`; Hop1-nonce `#GOOGLE-OIDC-C5`+`OIDC-CORE-C2`/`#OIDC-CORE-C5`; Hop2 `jwks-keycloak-key-rotation-active-passive.md#KC-ROT-C1`(자체 active key 서명); Hop3-sig `spring-security-resource-server-jwt.md#SSRS-JWT-C1`·`#SSRS-JWT-C2`; Hop3-iss `#SSRS-JWT-C1`·`#SSRS-JWT-C6`; Hop3-aud `#SSRS-JWT-C6`; Hop3-exp `#JWT-RFC7519-C2` | `official-standard + official-vendor-doc` (8/12 cell) | **4 cell 미해소**(§구현 가이드 §1 UNSUPPORTED_IMPL_DECISION): ① Hop1-aud 의 *Google-specific* "aud=Keycloak client_id"(generic RFC + OIDC Core 로 원리는 닫히나 Google 명시 quote 부재), ② Hop2 *default 알고리즘 RS256*(KC-ROT-C1 은 "자체 active key 서명"만 증명, RS256 명시 없음), ③ Keycloak-side nonce 자동 동작(D3 와 동일 gap). §Claims To Verify |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 본 branch 는 `documented-only` 학습 노트 — 실 구현 코드가 아니라 **각 hop 의 검증 계약**(누가·무엇을·어떻게 검증하는가)의 사전 명세다. 부모 P2B 가 이 §의 매트릭스를 hop 검증 owner 로 위임했다. 아래 sub-section 은 본 branch 결정(D1·D3·D5)에서만 도출한다.
|
||||
>
|
||||
> **3-rule**: R1 각 cell 은 Decision ID + Claim ID reference. R2 근거 없는 detail 은 `UNSUPPORTED_IMPL_DECISION` 라벨 + trade-off. R3 본 branch 범위 밖(발급측 aud 주입·KC_HOSTNAME 고정)은 owner 형제로 위임(§엣지·실패·의존 다른 계약 의존).
|
||||
|
||||
### 1. Hop별 검증 계약 (verifier → 대상 → 항목 → 근거)
|
||||
|
||||
> **Trace**: D5(전 cell) + D1(Hop3 단일 issuer 신뢰). 각 cell 은 아래 표의 Claim ID 로 근거. §진행 중 메모의 "누가 무엇을 검증하나" 통찰을 명세로 승격.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**:
|
||||
> - **Hop1-aud (Google-specific)**: "Google id_token 의 `aud` = Keycloak 이 등록한 Google client_id" 의 *Google 명시* verbatim 은 아카이브에 없음. generic 근거(`JWT-RFC7519-C1` aud MUST-reject) + OIDC Core(ID Token aud=RP client_id)로 *원리*는 닫히나, "Google 이 그렇게 발급한다" 는 `INFERENCE`. trade-off: OIDC RP-client 표준 의미상 거의 확실하나 FACT 승격은 Google Identity 페이지 또는 실 토큰 decode 필요(§Claims To Verify).
|
||||
> - **Hop2 서명 알고리즘 (RS256)**: `KC-ROT-C1` 은 "Keycloak 이 자체 active key pair 로 새 서명 생성"만 증명 — **default 알고리즘이 RS256 이라는 근거는 아카이브 부재**. trade-off: Keycloak 관례상 RS256 이 default 로 알려져 있으나 미검증 → 이 cell `needs-confirmation`.
|
||||
> - **Hop1-nonce (Keycloak-side)**: nonce 의 필요/검증 *원리*는 spec(§2 근거), 그러나 Keycloak 이 *자동으로* 송신/대조하는지는 미아카이브(D3 Open Risk 와 동일).
|
||||
|
||||
| Hop | 검증자 | 대상 | 검증 항목 | 근거 (Claim ID) | cell 등급 |
|
||||
|---|---|---|---|---|---|
|
||||
| Hop1 Google→Keycloak | Keycloak | Google id_token | signature RS256 (Google JWKS, local 검증) | `#GOOGLE-OIDC-C3`(RS256-only), `#GOOGLE-OIDC-C8`(retrieve keys + validate locally) | `documented-only` |
|
||||
| Hop1 | Keycloak | Google id_token | `iss` = `https://accounts.google.com` (정확 문자열) | `#GOOGLE-OIDC-C1` | `documented-only` |
|
||||
| Hop1 | Keycloak | Google id_token | `aud` = Keycloak 의 Google client_id | `#JWT-RFC7519-C1`(generic aud MUST-reject) + `OIDC-CORE-C1`/`#OIDC-CORE-C4`(ID Token aud=RP client_id) | ⚠️ `UNSUPPORTED_IMPL_DECISION` (Google-specific quote 부재) |
|
||||
| Hop1 | Keycloak | Google id_token | `exp` (만료 검증, clock-skew leeway) | `#JWT-RFC7519-C2` | `documented-only` |
|
||||
| Hop1 | Keycloak | Google id_token | `nonce` 일치 (replay 방지) | `#GOOGLE-OIDC-C5`(nonce Required) + `OIDC-CORE-C2`/`#OIDC-CORE-C5` | ⚠️ `UNSUPPORTED_IMPL_DECISION` (Keycloak 자동 동작 미검증) |
|
||||
| Hop2 Keycloak→SPA | (Keycloak 발급) | Keycloak access_token | 자체 active key 로 재서명 | `#KC-ROT-C1` (single active key pair → new signatures) | ⚠️ `needs-confirmation` (알고리즘 RS256 명시 부재) |
|
||||
| Hop3 SPA→Backend | Backend RS | Keycloak access_token | signature (Keycloak JWKS, discovery) | `#SSRS-JWT-C1`, `#SSRS-JWT-C2` | `documented-only` |
|
||||
| Hop3 | Backend RS | Keycloak access_token | `iss` = `https://kc/realms/{r}` (byte-match) | `#SSRS-JWT-C1`, `#SSRS-JWT-C6`("iss 가 아니면 validation fail") | `documented-only` |
|
||||
| Hop3 | Backend RS | Keycloak access_token | `aud` = backend-client-id | `#SSRS-JWT-C6` (audiences property → aud 검증) | `documented-only` (발급측 aud 주입은 형제 audience-validator D4 의존) |
|
||||
| Hop3 | Backend RS | Keycloak access_token | `exp` | `#JWT-RFC7519-C2` | `documented-only` |
|
||||
|
||||
### 2. audience 문자열 정합 규칙 (byte-match + 발급측 선행)
|
||||
|
||||
> **Trace**: D1 + D5 Hop3-iss(`#SSRS-JWT-C1`·`#SSRS-JWT-C6`) + Hop1-iss(`#GOOGLE-OIDC-C1`).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 없음 — 아래는 인용 claim 의 직접 도출.
|
||||
|
||||
| 항목 | 규칙 | 근거 | 의존(owner 형제) |
|
||||
|---|---|---|---|
|
||||
| Google `iss` | 정확히 `https://accounts.google.com` — bare-hostname alias 금지(비교 실패) | `#GOOGLE-OIDC-C1` (+ Does-not-prove: alias 부인) | — (CV1 correction) |
|
||||
| Keycloak `iss` (Hop3) | 백엔드 `issuer-uri` == token `iss` **byte-level** 일치. discovery 로 self-configure, 불일치 시 validation fail | `#SSRS-JWT-C1`, `#SSRS-JWT-C6` | [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] D1 (`KC_HOSTNAME` 고정으로 iss 안정화) |
|
||||
| Keycloak `aud` (Hop3) | `aud` 에 backend-client-id 포함해야 통과. 발급측이 안 넣으면 `aud=account` 만 → 정상 토큰도 거부 | `#SSRS-JWT-C6` | [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D4 (SPA client Audience mapper 선행) |
|
||||
| Google redirect URI | Keycloak broker endpoint 를 Google Console 에 정확 등록 — 불일치 시 `redirect_uri_mismatch` | `raw/official-docs/google-oauth2-redirect-uri-validation-official.md#GOOGLE-REDIR-C3` | [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]] D1~D4 (exact-match 규칙 deep owner) + [[raw/branch-notes/feature-keycloak-idp-brokering-google-client]] D3 (등록 step) |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4 캡처용. 본 branch 는 `documented-only` 이나, 3-leg trust 를 실제로 세울 때 부딪힐 실패/엣지와 다른 계약 의존을 미리 열거.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **Keycloak → Google JWKS refetch 실패 → Google 로그인 전체 5xx (가장 위험, 운영)**: Keycloak 이 Google `jwks_uri` 를 못 가져오면(네트워크 단절 / Google 측 변경) Hop1 signature 검증 불가 → 모든 Google 로그인 실패. 캐시 TTL·expired-key fallback 동작 **미검증**(D2/CV3 `UNSUPPORTED`). 기대 동작: 만료 시 자동 refetch(가정), monitoring alarm 필수.
|
||||
- **백엔드 issuer 오설정 → 모든 Google-originated token 거부**: 백엔드 `issuer-uri` 가 Keycloak realm URL 과 byte-level 불일치면 Hop3 에서 `iss` 검증 실패 → Google 로 로그인한 사용자 포함 **전 토큰 401**. 근거 `#SSRS-JWT-C6`("iss 가 아니면 validation will fail"). 기대 동작: 의도적 오설정 시 401 (CV4, local 재현 `planned`).
|
||||
- **Google `iss` alias 혼동 → Hop1 검증 실패**: `accounts.google.com`(bare)로 비교하면 `https://accounts.google.com` 발급 토큰이 불일치. 근거 `#GOOGLE-OIDC-C1`(정확 문자열). CV1 정정 사항 — 과거 "둘 다 허용" 은 folklore.
|
||||
- **nonce 미검증/재사용 → replay 취약**: Hop1 에서 nonce 대조를 안 하면 탈취된 id_token 재생 가능(D3). 단 Keycloak 자동 검증 여부 미검증.
|
||||
- **redirect URI 변조 → Google 거부**: Keycloak broker endpoint 외 URI 는 `redirect_uri_mismatch`(`#GOOGLE-REDIR-C3`). Keycloak broker URL 변경 시 Google Console 반영 필요.
|
||||
- **Keycloak 서명 알고리즘 가정 오류**: 백엔드가 RS256 을 가정하는데 Keycloak realm 이 다른 알고리즘이면 Hop3 signature 검증 실패. Hop2 알고리즘 default 는 `needs-confirmation`(§구현 가이드 §1).
|
||||
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-keycloak-internal-spa-direct-google-federation]] (부모 P2B) — 부모가 hop 검증 매트릭스 owner 를 **본 노트 D5 로 위임**(부모 §신뢰 경계 delegation). 본 노트 D5 가 바뀌면 부모 §신뢰 경계 개요 갱신 필요(비차단 전파).
|
||||
- [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] **D1**(백엔드 `iss`+sig+`exp`+`aud` 4종 검증) · **D4**(Keycloak Audience mapper 로 backend client_id 를 `aud` 에 주입) — 본 노트 **Hop3-aud cell** 이 그 계약을 consume. federation 환경에서도 `aud` 가 포함되는지는 §Claims To Verify CV5 로 그쪽에 위임.
|
||||
- [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] **D1**(`KC_HOSTNAME` 고정 → token `iss` 가 백엔드 `issuer-uri` 와 byte-match) — 본 노트 **Hop3-iss cell + D1** 성립의 전제. iss 불일치 함정의 재현·해결은 그 branch 소관.
|
||||
- [[raw/branch-notes/feature-keycloak-google-redirect-uri-policy]] **D1~D4**(redirect URI exact-match / byte-level 규칙 deep owner, GOOGLE-REDIR 근거 계열) + [[raw/branch-notes/feature-keycloak-idp-brokering-google-client]] **D3**(Authorized redirect URI 등록 step) — 본 노트 Hop1 redirect URI 변조 방지(CV7, `#GOOGLE-REDIR-C3`)가 이 계약에 의존.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| **[CV1 — resolved-as-contradiction]** Google `iss` 가 `https://accounts.google.com` 와 `accounts.google.com` 둘 다 spec 상 정당한가 | 원래 "둘 다 허용" 으로 적었으나 아카이브가 **반증** | ✅ **해소**: `raw/official-docs/google-oidc-discovery-spec.md#GOOGLE-OIDC-C1` — discovery JSON 은 `iss` 를 정확히 `https://accounts.google.com` 로만 규정, Does-not-prove 열이 alias 를 명시 부인. 본문 §마주친 문제의 "과거 사례" 는 미검증 folklore | `resolved` (documented-only) |
|
||||
| **[CV4 — resolved]** backend 가 Keycloak issuer 를 잘못 적으면 모든 Google-originated token 거부 | 메커니즘은 문서화, local 재현은 미실행 | ✅ 메커니즘 `raw/official-docs/spring-security-resource-server-jwt.md#SSRS-JWT-C6`("iss 가 아니면 validation will fail"). local 재현(`issuer-uri` 의도적 오설정 → 401)은 별도 `planned` | `documented-only` (재현 `planned`) |
|
||||
| **[CV7 — resolved]** Google Cloud Console redirect URI 정확 매칭(변조 방지) | Source 미등록이었음 | ✅ `raw/official-docs/google-oauth2-redirect-uri-validation-official.md#GOOGLE-REDIR-C3`(정확 매칭, else `redirect_uri_mismatch`). Sources 에 등록 완료 | `documented-only` |
|
||||
| **[CV2 — gap]** Google JWKS rotation 빈도 + Cache-Control / SLA | 아카이브 부재 — `google-openid-connect-oidc`·`google-oidc-discovery-spec` 어디에도 cadence/헤더 없음 | `https://www.googleapis.com/oauth2/v3/certs` 응답 헤더(`Cache-Control: max-age=...`) 직접 관찰 또는 Google 지원 페이지 아카이브 | `needs-confirmation` (research opt-in) |
|
||||
| **[CV3/D2 — gap]** Keycloak 의 외부 IdP(Google) JWKS 캐시 정책 (기본 TTL, expired-key fallback) | 아카이브 부재 — Keycloak IdP 문서 3개 확인했으나 미기술. `KC-ROT` 는 자체 키라 무관 | Keycloak Identity Provider admin config 페이지 또는 소스(`OIDCIdentityProvider`/`AbstractOAuth2IdentityProvider`) 정독 후 `raw/official-docs/` 등록 | `needs-confirmation` (research opt-in) |
|
||||
| **[CV5 — delegated]** Keycloak 발급 access_token 에 `aud=<backend-client-id>` 포함(federation 환경에서도) | 본 노트 Sources 범위 밖 — 발급측 결정 | [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] D4 + 실 토큰 decode. 본 노트는 Hop3-aud cell 로 consume | `needs-confirmation` (형제 위임) |
|
||||
| **[CV6/D3 — gap]** Keycloak 이 Google 요청 시 random `nonce` 동봉 + 응답 id_token nonce 자동 대조 | spec 은 "RP 가 해야 한다"만 증명, Keycloak 자동 동작은 미아카이브 | Keycloak OIDC Identity Provider config doc(nonce/PKCE 토글) 또는 소스 `OIDCIdentityProvider.createAuthenticationRequest()`, 또는 wire trace(HAR) | `needs-confirmation` (research opt-in) |
|
||||
| **[Hop2 — gap]** Keycloak 기본 서명 알고리즘이 RS256 인가 | `KC-ROT-C1` 은 "자체 active key 서명"만 증명, 알고리즘 명시 없음 | Keycloak realm "Keys" 탭 문서 또는 realm `default-signature-algorithm` provider config 확인 | `needs-confirmation` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- (학습 단계, 미실행)
|
||||
- **잠재적 함정 기록**:
|
||||
- ~~Google이 issuer 표기를 `https://accounts.google.com`로도 `accounts.google.com`로도 발급한 사례가 있음 (과거).~~ ⚠️ **정정(2026-07-18)**: 이 진술은 **미검증 folklore** 다. 아카이브 근거(`#GOOGLE-OIDC-C1`)는 `iss` 를 정확히 `https://accounts.google.com` 단일 문자열로 규정하고 alias 를 부인한다. 둘 다 유효했다는 2차 아카이브(예: 과거 Google OIDC discovery 스냅샷)가 나오기 전엔 "둘 다 허용" 으로 취급 금지. (CV1)
|
||||
- Keycloak이 Google JWKS를 가져오지 못하면 (네트워크 단절, Google 측 변경) Google 로그인 전체가 5xx. 모니터링 alarm 필요. (§엣지·실패·의존, D2/CV3)
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/openid-connect-core-id-token-validation]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
> 본 sub-sub-branch 는 leaf — 자식 자료 없음. Phase 3 P3A 실 구현 또는 외부 산출물 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 오류 기록 (이 sub-sub-branch 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 documented-only 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — Phase 3 실 구현 단계에 누적)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 학습 노트. P2B는 `documented-only` 유지.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: 학습 노트 (`documented-only`)
|
||||
- **wiki 추출 대상**:
|
||||
- `actually-implemented` 항목: (없음)
|
||||
- `locally-verified` 항목: (없음)
|
||||
- `prod-verified` 항목: (없음)
|
||||
- **추출하지 않을 항목**: 전 항목 (`documented-only` / `needs-confirmation`). 단 D2·CV2·CV3·CV6·Hop2 gap 종결 후 `wiki/concepts/keycloak-google-federation-trust-boundary` (hop 검증 매트릭스) 합성 후보(현 status `documented-only` 로 파생 게이트 미달).
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
---
|
||||
title: branch / feature-keycloak-traefik-forwardauth-alternative (AP4, legacy P1A — Traefik ForwardAuth 대안 비교)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-CHILD-35B79487
|
||||
kind: branch-child
|
||||
project: keycloak-patterns-overview
|
||||
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-014
|
||||
inherits: [DEC-KEYCLOAK-PATTERNS-OVERVIEW-EDGE-FORWARDAUTH-001@1, DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-KEYCLOAK-PATTERNS-OVERVIEW-012]
|
||||
contract_packet: 1
|
||||
branch: feature-keycloak-traefik-forwardauth-alternative
|
||||
parent_branch: feature-keycloak-header-spoofing-defense
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, p1a, traefik, forwardauth, ingress-route, middleware]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: 3f03d8906adb2a9bc3ac464995fbf1c04de09ab5d7ca341ea578cd8ab7ece490
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-traefik-forwardauth-alternative (AP4, legacy P1A — Traefik ForwardAuth 대안 비교)
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/branch-notes/feature-keycloak-header-spoofing-defense]]의 WI014 child branch. single-EC2는 배포 축이며 인증 패턴 ID를 AP1/P3A로 바꾸지 않는다.
|
||||
> 부모 sub-branch가 oauth2-proxy + nginx 조합을 중심으로 다뤘다면, 본 sub-sub는 **Traefik `forwardAuth` middleware** 단독 또는 oauth2-proxy 조합 시의 차이를 환경별(K8s mesh / Traefik IngressRoute / standalone Docker)로 비교.
|
||||
> 본 sub-sub-branch는 **문서까지만** (`documented-only`).
|
||||
> `status_label`: `in-progress`
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/branch-notes/feature-keycloak-header-spoofing-defense]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: forwarded-user spoofing 우회를 재현하고 network isolation 후 차단한다
|
||||
|
||||
<!-- section-id: inherited-project-decisions -->
|
||||
### 상속한 프로젝트 결정
|
||||
|
||||
| Decision Ref | Project Summary | Branch Application | Source |
|
||||
|---|---|---|---|
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-EDGE-FORWARDAUTH-001@1` | AP4는 oauth2-proxy ForwardAuth를 사용한다 | AP4 baseline과 Traefik ForwardAuth 대안의 경계를 비교한다 | [[raw/project-notes/keycloak-patterns-overview]] |
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1` | done-bar는 E2E success와 signature security failure 재현·해결 evidence다 | parent의 spoofing defense verification contract를 승계한다 | [[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 -->
|
||||
## 목표
|
||||
|
||||
P1A 패턴을 실 운영에 도입한다면 ingress 선택지는 크게 둘이다:
|
||||
1. **Ingress-Nginx + oauth2-proxy**: nginx `auth_request` directive로 oauth2-proxy 호출.
|
||||
2. **Traefik + ForwardAuth middleware**: Traefik의 `forwardAuth` middleware로 oauth2-proxy 또는 다른 auth server 호출 (또는 Traefik 자체 OIDC plugin).
|
||||
|
||||
본 sub-sub는 환경별(K8s with Traefik IngressRoute / standalone VM with docker-compose) 선택 기준을 정리하고, oauth2-proxy와의 결합 방식 차이(`authResponseHeaders` / `authRequestHeaders` / `tls.insecureSkipVerify`)를 분리해서 본다.
|
||||
|
||||
핵심 질문:
|
||||
1. Traefik `forwardAuth` middleware의 **응답 contract**는 nginx `auth_request`와 어떻게 다른가? (둘 다 2xx=allow / 4xx=deny이지만 redirect 처리 위치가 다름)
|
||||
2. K8s에서 Traefik IngressRoute CRD를 쓰면 어떤 trade-off가 발생하는가? (vendor-specific CRD vs 표준 Ingress 호환성)
|
||||
3. standalone Docker (단일 VM) 환경에서는 어느 쪽이 단순한가? → docker-compose label-based Traefik 라우팅이 우위.
|
||||
4. Traefik 자체에 OIDC plugin (community / enterprise)을 쓰면 oauth2-proxy 없이 1-hop으로 줄일 수 있는가? trade-off는?
|
||||
|
||||
- 이슈:
|
||||
- PR:
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- Traefik `forwardAuth` middleware 의 응답 contract·헤더 forwarding·TLS 옵션을 nginx `auth_request` 대비로 정리 (D3~D8, vendor doc 근거)
|
||||
- oauth2-proxy 결합 시 Traefik 측 헤더 주입/필터 방식 차이 (D4·D5·D6)
|
||||
- Traefik 이 oauth2-proxy 없이 OIDC 를 자체 수행할 수 있는가 3-way 비교: oauth2-proxy(baseline) / community in-process plugin / Traefik Hub(유료) (D10)
|
||||
- K8s 부착 방식 개념 비교: 표준 Ingress+annotation vs IngressRoute+Middleware CRD (D9, **문서 비교만**)
|
||||
- 환경별 선택 기준 매트릭스 (K8s / 단일 VM docker-compose / 학습·PoC)
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 실 K8s / Traefik 클러스터 구성·배포 — 프로젝트 실 구현은 single-EC2 docker-compose + nginx 로 고정([[raw/project-notes/keycloak-patterns-overview]] F5). D9 는 개념 비교까지만.
|
||||
- Traefik Hub 유료 구독 실사용 및 실 가격 확인 (GET PRICING gated)
|
||||
- community plugin 의 production 채택 — PoC 수준 검토만 (single-maintainer·production 사례 0건)
|
||||
- Kubernetes Gateway API (HTTPRoute) 경로 — Plan Gap, 별도 후속 sub-branch 후보
|
||||
- 모든 hands-on 실측(discovery cache TTL / token refresh 타이밍 / ArgoCD health 오탐 / redirect 호환) → `## Claims To Verify` 로 이월
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
> 부모 sub-branch 인용 자료 재참조 + 2026-07-18 `/branch-spec` 자동조사(D9·D10)로 Traefik Hub·community plugin 공식 자료 신규 archive.
|
||||
|
||||
- [[raw/official-docs/traefik-forwardauth-middleware-official]] — Traefik ForwardAuth middleware 공식 (K8s 환경 대안)
|
||||
- [[raw/official-docs/traefik-hub-oidc-middleware-official]] — Traefik OIDC 미들웨어 공식. **Traefik Hub(유료) 전용**임을 확정 — D10 근거 (community/OSS 에는 native OIDC 없음)
|
||||
- [[raw/official-docs/traefik-oidc-community-plugin-lukaszraczylo-official]] — Traefik Hub(유료) 대신 검토할 수 있는 **community Yaegi plugin** (`lukaszraczylo/traefikoidc`, MIT, single-maintainer). oauth2-proxy 를 제거하고 Traefik in-process 로 OIDC 를 수행하는 대안 경로 존재를 근거화하되, Traefik Labs 무보증 + production 사례 0건의 유지보수 리스크를 named failure mode 로 문서화 — D10 근거
|
||||
- (재참조) [[raw/official-docs/oauth2-proxy-overview-config-official]] — oauth2-proxy와의 비교 기준
|
||||
- (재참조) [[raw/official-docs/oauth2-proxy-nginx-integration-official]] — nginx 조합 대비 비교 기준
|
||||
|
||||
## TODO
|
||||
|
||||
각 항목 옆에 증거 등급 표기.
|
||||
|
||||
- [ ] Traefik `forwardAuth` middleware 설정 정리 (`address`, `authResponseHeaders`, `authRequestHeaders`, `trustForwardHeader`, `tls.insecureSkipVerify`) — 등급: `planned`
|
||||
- [ ] K8s 환경에서 Traefik IngressRoute CRD + Middleware CRD 예제 작성 — 등급: `planned`
|
||||
- [ ] IngressRoute CRD의 vendor lock-in 정리 (표준 Ingress 리소스와의 호환성, ArgoCD 운영 차이) — 등급: `planned`
|
||||
- [ ] standalone Docker compose 환경에서 Traefik label-based 라우팅 + ForwardAuth middleware 예제 — 등급: `planned`
|
||||
- [ ] oauth2-proxy vs Traefik ForwardAuth + (Traefik OIDC plugin / oauth2-proxy 결합) 비교표 — 등급: `planned`
|
||||
- [ ] 비교 항목: 응답 헤더 propagation 방식 / redirect 처리 위치 / cookie/세션 관리 주체 / K8s native 정도 / 운영 복잡도 / 커뮤니티 활성도 — 등급: `planned`
|
||||
- [ ] Traefik의 `authResponseHeaders`가 동작하지 않는 함정 정리 (regex / 대소문자 / header 이름 정규화) — 등급: `planned`
|
||||
- [ ] 환경별 권장 정리: K8s mesh 환경 / Traefik IngressRoute 운영 환경 / standalone VM docker-compose — 등급: `planned`
|
||||
- [ ] Traefik standalone에서 단일 VM docker-compose로 묶을 때의 단순성 정리 (P3A와의 연결점) — 등급: `planned`
|
||||
- [ ] Traefik 자체 OIDC plugin 옵션 검토 (community plugin / Traefik Hub 유료) — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
> 작업하며 떠오른 메모.
|
||||
|
||||
- 부모 sub-branch §결정 사항에서 이미 oauth2-proxy(K8s+Ingress-Nginx 환경) vs Traefik ForwardAuth(이미 Traefik 사용 환경 또는 단일 VM compose) 선택 기준을 적어두었음. 본 sub-sub는 그 결정을 **환경별 4-셀 매트릭스**로 분해.
|
||||
- Traefik ForwardAuth middleware는 `authResponseHeaders`에 명시한 헤더만 backend로 전달. nginx 측 `auth_request_set` + `proxy_set_header` 두 단계와 달리 middleware config 한 줄로 끝나는 단순함.
|
||||
- 단, 외부 auth service(oauth2-proxy)가 302와 `Location`을 생성하고 Traefik ForwardAuth middleware는 그 non-2XX 응답을 browser에 전달한다. 생성 주체와 전달 주체를 나눠 디버깅한다.
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- **2026-05-25 (정합 2026-07-18)**: 본 문서는 **AP4 Edge ForwardAuth**의 Traefik 대안 비교다. legacy P1A의 nginx+oauth2-proxy baseline도 AP4이며, P3A/AP1 SPA-direct와는 다른 인증 패턴이다. single-EC2는 어느 패턴에도 적용 가능한 배포 축일 뿐이다.
|
||||
- **2026-05-25 (decision candidate)**: standalone Docker compose 환경에서 단일 VM에 모두 올릴 경우, Traefik label-based 라우팅이 nginx config 파일 관리보다 단순. 단, 본 학습 프로젝트는 P3A를 nginx로 결정했으므로 본 sub-sub는 학습/비교 목적.
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시. Traefik forwardAuth 자체의 동작은 공식 vendor doc 으로 직접 뒷받침. nginx 대비 운영 단순도 / 환경별 추천은 vendor doc 인용 없는 비교 판단 → 그 부분만 UNSUPPORTED_DECISION.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | 본 sub-sub는 AP4의 Traefik ForwardAuth 선택 기준을 정리하고 실 배포는 별도 구현 branch로 분리 | 문서 비교가 목적이면 여기서 종결 / hands-on이 필요하면 AP4 owner 아래 구현 branch로 분리 | [[raw/project-notes/keycloak-patterns-overview]] F1 + organizational decision | `delegated + organizational` | vendor별 hands-on 없이 실제 운영 함정 누락 가능 |
|
||||
| D2 | AP4 baseline은 nginx+oauth2-proxy, Traefik은 이미 Traefik을 쓰는 환경의 대안 | 인증 패턴은 AP4로 고정하고 ingress 구현체만 선택한다. single-EC2 배포 여부는 별도 축 | [[raw/branch-notes/feature-keycloak-edge-forwardauth-no-google]] D1/D3 | `delegated` | nginx/Traefik 운영 단순도는 실측 없음 |
|
||||
| D3 | Traefik forwardAuth 응답 contract: 2XX → allow + 원본 요청 진행, 비 2XX → 인증 서버 응답 그대로 client 에 반환 (nginx 의 401/403 specific deny 와 다름) | N/A (vendor spec 사실 — 분기 아님) | `raw/official-docs/traefik-forwardauth-middleware-official.md#TFA-C1`, `raw/official-docs/nginx-auth-request-module-official.md#NGAR-C2` (비교 baseline) | `official-vendor-doc` (Traefik 공식 + nginx 공식 양측 verbatim) | 비 2XX 응답이 그대로 client 에 전달되는 동작이 oauth2-proxy 의 302 sign_in redirect 와 완벽 호환된다는 보장 없음 (`TFA-C1` does-not-prove) — 실 시연 검증 필요 |
|
||||
| D4 | Traefik 은 인증 서버로 5개 헤더 자동 forward: `X-Forwarded-Method`, `X-Forwarded-Proto`, `X-Forwarded-Host`, `X-Forwarded-Uri`, `X-Forwarded-For` | N/A (vendor spec 사실) | `raw/official-docs/traefik-forwardauth-middleware-official.md#TFA-C2` | `official-vendor-doc` | oauth2-proxy 가 이 5종 헤더를 모두 인식한다는 보장 없음 — oauth2-proxy 측 `--reverse-proxy=true` 옵션 필요 (별도 검증) |
|
||||
| D5 | `authResponseHeaders` 한 줄로 user 헤더 주입 가능 (nginx 의 `auth_request_set` + `proxy_set_header` 2-step 대비 단순) | ingress 가 Traefik → `authResponseHeaders` 한 줄 / ingress 가 nginx → `auth_request_set`+`proxy_set_header` 2-step | `raw/official-docs/traefik-forwardauth-middleware-official.md#TFA-C3`, `raw/official-docs/nginx-auth-request-module-official.md#NGAR-C5` (비교 baseline) | `official-vendor-doc` (Traefik 공식 + nginx 공식 양측 verbatim) | `authResponseHeaders` 의 replace 동작이 case-insensitive 인지 verbatim 미확정 (`TFA-C3` does-not-prove) — 헤더 이름 정규화 함정 가능 |
|
||||
| D6 | `authRequestHeaders` 로 인증 서버로 전달할 헤더 필터링 (default empty = 모든 헤더 전달 — production 위험) | production → 명시 화이트리스트 필수 / dev·PoC → default(empty) 허용 가능하나 sensitive header 노출 인지 | `raw/official-docs/traefik-forwardauth-middleware-official.md#TFA-C4` | `official-vendor-doc` | default (empty) 가 Authorization 등 sensitive header 까지 인증 서버로 보내 production 위험 — 명시 화이트리스트 필요 |
|
||||
| D7 | `tls.insecureSkipVerify` 는 dev only — production 금지 (vendor 가 명시적으로 risk 경고) | dev·self-signed cert → true 허용 / production → false + `tls.ca`/`tls.cert` 발급·회전 | `raw/official-docs/traefik-forwardauth-middleware-official.md#TFA-C5` | `official-vendor-doc` | self-signed cert 운영 시 `tls.ca` / `tls.cert` 발급 / 회전 운영 비용 발생 |
|
||||
| D8 | `trustForwardHeader=true` 회피 (deprecated marker) | 신규 구성 → 사용 회피(deprecated) / 기존 구성 마이그레이션 → 대체 옵션 확인 후 전환 | `raw/official-docs/traefik-forwardauth-middleware-official.md#TFA-C6` | `official-vendor-doc` | 대체 옵션의 정확한 신규 이름은 본 인용에 없음 — 별도 deprecated 경고 페이지 추가 raw 보존 필요 |
|
||||
| D9 | K8s ForwardAuth 부착: 표준 Ingress+`router.middlewares` annotation vs IngressRoute+Middleware CRD. **핵심 정정** — 표준 Ingress 도 `@kubernetescrd` 로 Middleware CRD 에 의존 → "완전 vendor-neutral" 아님; 실 차이는 route 객체 이식성 + ArgoCD health 평가 여부 | ArgoCD 운영 가시성·ingress 교체 가능성 우선 → 표준 Ingress+annotation (route skeleton 이 built-in health 대상, 부분 이식) / Traefik 단독 확정 + 구조화 spec·고급 라우팅 우선 → IngressRoute+Middleware CRD (단 ArgoCD custom Lua health check 비용) | researched 2026-07-18 (wiki-decision-researcher) — 근거 URL 확보(archival 후보): Traefik K8s Ingress/CRD 공식 doc, ArgoCD resource-health doc, K8s ingress-controllers doc. **K8s 는 실 구현 범위 밖(F5)이라 raw archival deferred** | UNSUPPORTED_DECISION (research-informed; archival deferred — K8s out of impl scope) | ArgoCD 가 IngressRoute/Middleware CRD 를 health 미평가 → 깨진 route 도 'Healthy' 오탐(silent-failure) 가능. Gateway API(HTTPRoute) 3번째 경로 미검토(Plan Gap). production 채택 빈도 근거 0 |
|
||||
| D10 | Traefik 자체 OIDC 3-way: oauth2-proxy(외부 서버, 채택 유지) vs community in-process plugin(예 `lukaszraczylo/traefikoidc`, PoC 한정) vs Traefik Hub(1st-party, 유료, 예산 없어 보류). OSS Traefik 에는 native OIDC 없음이 확정됨 | 이식성·성숙도 우선 또는 K8s+nginx → oauth2-proxy 유지 / Traefik 전용 확정 + 학습·PoC → community plugin 스파이크(production 미채택) / 1st-party 지원·SLA + 예산 확보 → Traefik Hub | `raw/official-docs/traefik-hub-oidc-middleware-official.md#THUB-C1` (OSS 에 native OIDC 없음 → Hub 유료 전용 확정), `raw/official-docs/traefik-oidc-community-plugin-lukaszraczylo-official.md#TOIDC-C1~C6` (community in-process 대안 존재·설정·리스크) + UNSUPPORTED (Hub 실 가격 GET PRICING gated) | `official-vendor-doc` (THUB-C1 Hub-exclusive) + `official-vendor-doc (self-published, community, Traefik Labs 무보증)` (community plugin) + UNSUPPORTED (Hub pricing) | community plugin OIDC discovery TTL / token refresh 실측 없음 (자기서술만, `TOIDC-C2`/`TOIDC-C3` does-not-prove) + production 사례 0건 + Traefik 업그레이드 시 plugin 로드 실패 coupling(`TOIDC-C5`) + Hub 실 가격 미확보 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 본 sub-sub 는 `documented-only` — "구현"의 산출물은 **비교 문서(매트릭스)** 이다. 아래는 그 deliverable 의 구조 명세이며, 각 표는 위 Decision + Supporting Claim 에서 도출된다(CLAUDE.md §15.5 R1). 실 코드/manifest 예제는 프로젝트 실 구현 범위(single-EC2 docker-compose) 밖이므로 작성하지 않고 개념 비교표로 종결한다(R3 OUT_OF_BRANCH_SCOPE).
|
||||
|
||||
### 1. Traefik vs nginx ForwardAuth 대비표 (핵심 deliverable)
|
||||
|
||||
> **Trace**: D3(`TFA-C1`)·D4(`TFA-C2`)·D5(`TFA-C3`)·D6(`TFA-C4`)·D7(`TFA-C5`)·D8(`TFA-C6`) — 각 행이 Traefik 공식 claim 으로 종결(nginx 측은 `NGAR-*` baseline).
|
||||
|
||||
| 비교 항목 | Traefik forwardAuth | nginx auth_request | 근거 |
|
||||
|---|---|---|---|
|
||||
| 응답 contract | 2XX allow / non-2XX 응답 그대로 client 전달 | 401·403 만 deny, 그 외 error | D3 |
|
||||
| 자동 forward 헤더 | `X-Forwarded-{Method,Proto,Host,Uri,For}` 5종 | 수동 `proxy_set_header` | D4 |
|
||||
| user 헤더 주입 | `authResponseHeaders` 한 줄 | `auth_request_set`+`proxy_set_header` 2-step | D5 |
|
||||
| 요청 헤더 필터 | `authRequestHeaders` (empty=전체 전달) | 명시 pass 필요 | D6 |
|
||||
| 인증서버 TLS | `tls.insecureSkipVerify`(dev only) | `proxy_ssl_verify` | D7 |
|
||||
| deprecated marker | `trustForwardHeader=true`(deprecated) | — | D8 |
|
||||
|
||||
### 2. Traefik 자체 OIDC 3-way 결정표 (D10 deliverable)
|
||||
|
||||
> **Trace**: D10 — `THUB-C1`(OSS 에 native OIDC 없음→Hub 유료 전용) + `TOIDC-C1~C6`(community in-process plugin 존재·설정·리스크).
|
||||
|
||||
| 옵션 | 아키텍처 | 비용/지원 | 선택 조건 |
|
||||
|---|---|---|---|
|
||||
| oauth2-proxy | 외부 서버(extra hop) | 무료·대규모 커뮤니티 | 이식성·성숙도 우선, K8s+nginx (baseline 유지) |
|
||||
| community plugin | Traefik in-process | 무료·Traefik Labs 무보증·single-maintainer | Traefik 전용 확정 + 학습·PoC (production 미채택) |
|
||||
| Traefik Hub | Traefik in-process | 유료(GET PRICING)·1st-party SLA | 지원·SLA 필수 + 예산 확보 |
|
||||
|
||||
### 3. K8s 부착 방식 비교 (D9) — 개념만
|
||||
|
||||
> **Trace**: D9 — research-informed(2026-07-18), UNSUPPORTED(archival deferred).
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: 실 IngressRoute/Ingress manifest 예제는 작성하지 않는다. trade-off — K8s 배포는 cross-cutting 이라 auth 아키텍처를 바꾸지 않고([[raw/project-notes/keycloak-patterns-overview]] F5) 프로젝트 실 구현은 single-EC2 docker-compose 라, 코드 예제 없이 개념 비교표로 충분. 실 시연이 필요해지면 별도 K8s branch 로 분리.
|
||||
|
||||
| 부착 방식 | route 객체 이식성 | ArgoCD health | Middleware CRD 의존 |
|
||||
|---|---|---|---|
|
||||
| 표준 Ingress+annotation | 부분(route skeleton 표준) | built-in health check 대상 | 남음(`@kubernetescrd`) |
|
||||
| IngressRoute+Middleware CRD | 없음(전량 Traefik) | 미평가→오탐 위험, custom Lua 필요 | 남음 |
|
||||
|
||||
### 4. 환경별 권장 매트릭스 (D1·D2·D9·D10 선택조건 종합)
|
||||
|
||||
> **Trace**: D1·D2·D9·D10 의 `선택 조건` cell 종합.
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: "단일 VM docker-compose 에서 Traefik label 라우팅이 nginx config 보다 단순"(D2) 은 정량 baseline 없는 자체 판단 → `## Claims To Verify` 로 실측 이월. 라벨 근거: 사용자 trade-off(학습 프로젝트는 nginx 로 P3A 확정, Traefik 우위는 미검증).
|
||||
|
||||
| 환경 | 권장 |
|
||||
|---|---|
|
||||
| 이미 Traefik 사용 K8s | Traefik forwardAuth + (oauth2-proxy 또는 Hub OIDC) |
|
||||
| K8s + 기존 nginx | nginx auth_request + oauth2-proxy (P1A baseline) |
|
||||
| 단일 VM docker-compose에서 AP4 실행 | nginx+oauth2-proxy baseline. Traefik label 라우팅 우위는 미검증(D2) |
|
||||
| single-EC2에서 AP1/P3A 실행 | SPA-direct + backend JWT validation이며 oauth2-proxy/ForwardAuth를 추가하지 않음 |
|
||||
| 학습·PoC + Traefik 전용 | community plugin 스파이크 검토 |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4 캡처용. 문서 단계이나, 실 구성 시 부딪힐 실패/엣지와 다른 계약 의존을 미리 열거.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- Traefik 이 non-2XX 를 그대로 client 에 전달(D3) → oauth2-proxy 302 sign_in redirect 가 브라우저에 온전히 전달되는지 미검증. 실패 시 로그인 redirect 끊김(백지/CORS).
|
||||
- `authResponseHeaders` 헤더 이름 정규화(case-insensitive?) 미확정(D5) → user 헤더가 backend 에 안 닿으면 인증 우회가 아니라 인증 실패(403 루프).
|
||||
- `authRequestHeaders` default empty(D6) → `Authorization`·`Cookie` 등 sensitive 헤더가 인증 서버로 새어나감. production 위험.
|
||||
- `tls.insecureSkipVerify=true`(D7) 를 production 에 방치 → 인증 서버 구간 MITM.
|
||||
- community plugin 이 Traefik helm chart release 에 coupling(`TOIDC-C5`) → Traefik 업그레이드 시 plugin 로드 실패 → 인증 전면 중단.
|
||||
- (K8s, D9) IngressRoute 가 깨져도 ArgoCD 가 'Healthy' 오탐 → 인증 장애가 조용히 방치.
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-keycloak-edge-forwardauth-no-google]] **D1**(oauth2-proxy vs Traefik ForwardAuth 선택 기준)·**D3**(Edge ForwardAuth 패턴 채택)에 의존 — 본 branch 의 Traefik 경로는 그 oauth2-proxy contract(`/oauth2/auth` endpoint, `--reverse-proxy=true`)를 consume. 그 결정이 바뀌면 본 노트 D3·D4 영향.
|
||||
- [[raw/branch-notes/feature-keycloak-header-spoofing-defense]] **D1**(네트워크 경계가 1차 방어, 헤더 검증은 trust-on-message)에 의존 — Traefik 도 헤더 신뢰 모델이라 동일한 network 격리 필요. `X-Forwarded-*` 위조 방어(NetworkPolicy/SG)는 그 branch 가 owner.
|
||||
- [[raw/project-notes/keycloak-patterns-overview]] F5(실 구현 = single-EC2 docker-compose)에 의존 — D9(K8s 경로)를 실 구현하지 않는 근거.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 vendor docs 가 옵션의 존재와 contract 를 증명해도 내 학습 / 실 운영 환경에서의 동작은 별개. 다음은 실 K8s 또는 docker-compose 시연 시 실측 필요.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| Traefik 의 non-2XX response 그대로 전달 동작이 oauth2-proxy 의 302 sign_in redirect 와 완벽 호환되는지 | `TFA-C1` does-not-prove "302 redirect 가 그대로 브라우저에 전달되어 자연스럽게 Keycloak 로그인으로 이동" | Traefik + oauth2-proxy 결합 후 미인증 브라우저 요청 → Location 헤더 추적 | `needs-confirmation` |
|
||||
| `authResponseHeaders` 의 헤더 이름 매칭이 case-insensitive 인지 (HTTP spec 은 그렇지만 vendor 별 상이 가능) | `TFA-C3` does-not-prove "case-insensitive 매칭" | `X-Auth-Request-User` 와 `x-auth-request-user` 양쪽 시도로 backend 도달 여부 확인 | `planned` |
|
||||
| Traefik IngressRoute CRD 사용 시 ArgoCD GitOps 운영에서 발생하는 sync drift / plural resource 인식 문제 | `D9` UNSUPPORTED — Traefik 공식의 K8s integration 페이지 verbatim 미확보 | ArgoCD 에 IngressRoute manifest 배포 후 sync status / health 확인 | `planned` |
|
||||
| standalone docker-compose 환경에서 Traefik label-based 라우팅이 실제로 nginx config 파일 관리보다 단순한가 | `D2` UNSUPPORTED — 비교의 정량적 baseline 없음 | 동일 SPA + API + Keycloak 구성을 nginx config 와 Traefik labels 두 방식으로 작성 후 line count / 변경 빈도 비교 | `planned` |
|
||||
| Traefik community OIDC plugin 의 discovery cache TTL / token refresh 부하 하 동작 | `TOIDC-C2`("bounded caches")·`TOIDC-C3`(`refreshGracePeriodSeconds`) 는 config knob·자기서술일 뿐 TTL 초 단위·부하 하 동작 미증명 | community plugin 활성화 후 `/.well-known/openid-configuration` 재요청 주기 + near-expiry 토큰 refresh 타이밍 관측 | `planned` |
|
||||
| `trustForwardHeader=true` deprecated 대체 옵션의 정확한 이름과 동작 | `TFA-C6` does-not-prove "대체 옵션 명" | Traefik 공식 deprecated 경고 페이지 추가 raw 보존 후 신규 옵션명 정확 인용 | `needs-confirmation` |
|
||||
| Traefik 의 5종 자동 forward 헤더 (`TFA-C2`) 를 oauth2-proxy 가 모두 인식하는지 (특히 `X-Forwarded-Method`) | `D4` 의 Open Risk — oauth2-proxy 가 method 헤더를 routing 결정에 사용하는지 vendor 인용 없음 | oauth2-proxy 로그 + Keycloak 측 access log 에서 method 추출 동작 확인 | `planned` |
|
||||
| 표준 Ingress+annotation 이 IngressRoute inline middleware 와 기능 100% 동등한지 (옵션 누락 여부) | research residual(`D9`) — annotation 문법만 확인, 기능 동등성 표 미확보 | 동일 ForwardAuth 를 두 방식으로 배포 후 응답 헤더 diff | `planned` |
|
||||
| IngressRoute/Middleware CRD 가 ArgoCD 에서 깨져도 'Healthy' 오탐(false-positive) 을 실제로 내는지 | `D9` — ArgoCD 공식은 "미평가 CRD=기본 Healthy" 일반 규칙만, Traefik CRD 이름 미언급(적용 추론) | IngressRoute 를 의도적으로 깨뜨린 뒤 `argocd app get` health 상태 확인 | `planned` |
|
||||
| Traefik Hub OIDC 미들웨어의 본 프로젝트 규모 실 가격 | `THUB`/`D10` — 가격은 GET PRICING gated, JS 렌더로 미확보 | Traefik Labs sales 문의로 견적 | `planned` |
|
||||
| Kubernetes Gateway API(HTTPRoute)+Traefik provider 가 D9 vendor-lock-in 을 실제로 해소하는지 | Plan Gap — 본 조사 범위 밖(`D9` 2-way 프레이밍) | 별도 sub-branch 로 HTTPRoute+Traefik provider 시연, route 객체 이식성 실측 | `planned` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- 아직 없음(문서 단계).
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/traefik-forwardauth-middleware-official]]
|
||||
- [[raw/official-docs/traefik-hub-oidc-middleware-official]]
|
||||
- [[raw/official-docs/traefik-oidc-community-plugin-lukaszraczylo-official]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
> 본 sub-sub-branch 는 leaf — 자식 자료 없음. Phase 3 P3A 실 구현 또는 외부 산출물 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 오류 기록 (이 sub-sub-branch 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 documented-only 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — Phase 3 실 구현 단계에 누적)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 본 sub-sub-branch는 **문서까지만**. wiki 추출은 root branch의 비교 매트릭스 시점에 일괄 처리.
|
||||
|
||||
- PR 링크:
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: 해당 없음 (문서 단계)
|
||||
- **wiki 추출 대상** (verified만, `wiki/projects/`로만 추출):
|
||||
- `actually-implemented` 항목: 없음
|
||||
- `locally-verified` 항목: 없음
|
||||
- `prod-verified` 항목: 없음
|
||||
- **추출하지 않을 항목** (planned / documented-only / abandoned):
|
||||
- 본 sub-sub-branch 전체가 `documented-only` 등급. P1A 부모 sub-branch와 함께 추후 비교 매트릭스 / `wiki/concepts/keycloak-deployment-patterns.md`에 인용되는 형태로만 활용. `wiki/projects/` 직접 승급 없음.
|
||||
+305
@@ -0,0 +1,305 @@
|
||||
---
|
||||
title: branch / feature-keycloak-vanilla-js-spa-pkce (vanilla JS SPA — Authorization Code + PKCE)
|
||||
source_type: branch-note
|
||||
status: raw
|
||||
id: BR-KEYCLOAK-PATTERNS-OVERVIEW-003
|
||||
kind: project-work-item
|
||||
project: keycloak-patterns-overview
|
||||
work_item: WI-KEYCLOAK-PATTERNS-OVERVIEW-003
|
||||
inherits: [DEC-KEYCLOAK-PATTERNS-OVERVIEW-SPA-CLIENT-001@1, DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1]
|
||||
refines: []
|
||||
overrides: []
|
||||
depends_on: [WI-KEYCLOAK-PATTERNS-OVERVIEW-002]
|
||||
contract_packet: 1
|
||||
branch: feature-keycloak-vanilla-js-spa-pkce
|
||||
parent_branch:
|
||||
related_projects: [keycloak-patterns]
|
||||
tags: [branch, keycloak-patterns, p3a, implementation, vanilla-js, spa, pkce, oidc-client-ts]
|
||||
created: 2026-05-25
|
||||
target_merge:
|
||||
status_label: in-progress
|
||||
contract_packet_sha256: bb1be862636aa363e6d10eb54600075ab82106f6c707a98acbfd84a938adf0f4
|
||||
---
|
||||
|
||||
# branch: feature-keycloak-vanilla-js-spa-pkce (vanilla JS SPA — Authorization Code + PKCE)
|
||||
|
||||
> Layer: `raw/branch-notes/` — [[raw/project-notes/keycloak-patterns-overview]]의 `WI-KEYCLOAK-PATTERNS-OVERVIEW-003` 직접 branch.
|
||||
> **P3A는 실 구현 대상**. 본 sub-sub는 학습 + 작업 plan 기록 — 실 구현은 `/home/donghyeon/workspace/keycloak-patterns/`.
|
||||
|
||||
<!-- section-id: branch-parent -->
|
||||
## 부모 (필수)
|
||||
|
||||
[[raw/project-notes/keycloak-patterns-overview]]
|
||||
|
||||
<!-- GENERATED: branch-contract:start -->
|
||||
<!-- section-id: branch-contract-packet -->
|
||||
## 브랜치 계약 패킷
|
||||
|
||||
- **생성 시 프로젝트 개정**: `1`
|
||||
- **패킷 스키마**: `contract_packet: 1`
|
||||
- **완료 조건**: vanilla JS PKCE login·token 수령·protected API 200이 재현된다
|
||||
|
||||
<!-- 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를 사용한다 | vanilla JS SPA의 Authorization Code + PKCE flow에 적용한다 | [[raw/project-notes/keycloak-patterns-overview]] |
|
||||
| `DEC-KEYCLOAK-PATTERNS-OVERVIEW-ACCEPTANCE-001@1` | done-bar는 E2E success와 signature security failure 재현·해결 evidence다 | login·token 수령·protected API 200을 E2E 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 -->
|
||||
## 목표
|
||||
|
||||
vanilla JS (no React/Vue/Angular)로 OIDC Authorization Code + PKCE 흐름을 직접 구현한다. `oidc-client-ts` 우선 채택 후, **별도 학습 단계에서** manual `crypto.subtle` 기반 PKCE 비교 구현. login button → Keycloak redirect → callback → token storage → `/api/me` 호출 → silent renew → logout 전체 lifecycle.
|
||||
|
||||
면접 질문: "PKCE 흐름을 코드로 설명해 주세요."
|
||||
→ "SPA가 `code_verifier` 43–128자 랜덤 생성, `code_challenge = BASE64URL(SHA256(code_verifier))`로 변환합니다. authorize 요청에 `code_challenge`와 `code_challenge_method=S256`을 첨부하고, 콜백에서 받은 `code`로 token 교환할 때 원본 `code_verifier`를 함께 보냅니다. authorization code interception attack 방어 — public client는 client_secret이 없으므로 PKCE가 사실상 필수입니다."
|
||||
|
||||
- 이슈:
|
||||
- PR: (별도 keycloak-patterns repo)
|
||||
|
||||
<!-- section-id: branch-scope -->
|
||||
## 범위
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- `index.html` (login button, logout button, `/api/me` 호출 결과 표시 영역)
|
||||
- `app.js` (`oidc-client-ts` `UserManager` 사용)
|
||||
- `callback.html` (redirect callback 처리 페이지) — 또는 main page에서 `?code=...` 감지
|
||||
- PKCE S256 (oidc-client-ts 내부 처리)
|
||||
- token storage: in-memory (학습용, `UserManager.events.addUserLoaded(...)`로 closure 보관)
|
||||
- silent renew (`automaticSilentRenew: true`)
|
||||
- `Authorization: Bearer ${user.access_token}` 헤더로 `/api/me` 호출
|
||||
- logout button → `signoutRedirect()` (Keycloak `/logout` endpoint)
|
||||
- (별도 단계) manual PKCE: `crypto.subtle.digest('SHA-256', ...)` + base64url encoding 직접 구현
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- React/Vue/Angular framework 사용 (vanilla 학습 목적)
|
||||
- iframe 기반 silent SSO (deprecated, 대신 refresh token 사용)
|
||||
- 자체 token storage 암호화
|
||||
- mobile / native client (PKCE 자체는 동일, 본 sub는 SPA)
|
||||
|
||||
## 근거 (필수, 최소 1개+)
|
||||
|
||||
- [[raw/official-docs/oidc-client-ts-library]] — oidc-client-ts 공식 (D1·D3 근거: PKCE·refresh·silent iframe 지원)
|
||||
- [[raw/official-docs/oauth2-pkce-rfc-7636]] — RFC 7636 PKCE (D4·§구현가이드 5 근거: verifier/challenge·S256 공식)
|
||||
- [[raw/official-docs/oauth-v2-1-draft-ietf]] — OAuth 2.1 draft (2026-07-18 `/branch-spec` 자동조사로 추가: D4 implicit 제거 `OA21-C2`, D5 redirect_uri exact-match `OA21-C5` 근거)
|
||||
- [[raw/official-docs/keycloak-client-pkce-method-enforcement-official]] — Keycloak client 등록 시 "PKCE method" 옵션 확인 근거 (server-side 강제는 owner sibling [[raw/branch-notes/feature-keycloak-realm-client-export]] 소유; `KC-PKCE-C1`/`C3`)
|
||||
|
||||
## TODO
|
||||
|
||||
- [ ] `npm init` + `oidc-client-ts` 설치 — 등급: `planned`
|
||||
- [ ] `index.html`: login button (id=`login`), logout button (id=`logout`), result 영역 (id=`result`) — 등급: `planned`
|
||||
- [ ] `app.js`: `UserManager` 인스턴스 — 등급: `planned`
|
||||
- `authority: 'http://localhost:8080/realms/keycloak-patterns'`
|
||||
- `client_id: 'spa-client'`
|
||||
- `redirect_uri: 'http://localhost/callback.html'`
|
||||
- `post_logout_redirect_uri: 'http://localhost/'`
|
||||
- `response_type: 'code'`
|
||||
- `scope: 'openid profile'`
|
||||
- `automaticSilentRenew: true`
|
||||
- [ ] login button click → `userManager.signinRedirect()` — 등급: `planned`
|
||||
- [ ] `callback.html`: `<script>` → `new UserManager(config).signinRedirectCallback().then(user => location.href='/')` — 등급: `planned`
|
||||
- [ ] main page load 시 `userManager.getUser()` → memory user가 있으면 runtime backend URL로 API 호출, reload로 없으면 재인증 — 등급: `planned`
|
||||
- [ ] `fetch('http://localhost:8081/api/me', { headers: { Authorization: 'Bearer ' + user.access_token } })` 또는 동일 값을 주입한 `runtimeConfig.backendBaseUrl` 사용 → JSON render — 등급: `planned`
|
||||
- [ ] logout button click → `userManager.signoutRedirect()` — 등급: `planned`
|
||||
- [ ] silent renew 검증: access token 만료 (5분) 직전 자동 갱신 발생 → DevTools Network 탭에서 `/token` (`grant_type=refresh_token`) 호출 확인 — 등급: `planned`
|
||||
- [ ] CORS 검증: nginx 80 → backend 8081 호출 시 preflight 통과 — 등급: `planned`
|
||||
- [ ] (별도 단계) manual PKCE 구현: — 등급: `planned`
|
||||
- `crypto.getRandomValues(new Uint8Array(32))` → base64url → `code_verifier`
|
||||
- `crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))` → base64url → `code_challenge`
|
||||
- `sessionStorage.setItem('pkce_verifier', verifier)`
|
||||
- manual token exchange는 OIDC discovery의 `token_endpoint` absolute URL 사용 (`grant_type=authorization_code` + `code_verifier`)
|
||||
- [ ] (별도 단계) oidc-client-ts vs manual 동작 비교 — 등급: `planned`
|
||||
|
||||
## 진행 중 메모
|
||||
|
||||
- **`oidc-client-ts` 우선 채택 이유**: production-grade silent renew / state / nonce / token validation을 한 줄로 처리. 학습 후 manual로 내부 동작 검증.
|
||||
- **token storage**: pinned oidc-client-ts version의 `stateStore`/`userStore` default는 아직 미검증이다. default에 의존하지 않고 **token/user store는 explicit in-memory**로 선택한다. redirect transaction state만 full-page callback 생존을 위해 explicit sessionStorage에 두고 callback 직후 정리한다.
|
||||
- **redirect_uri**: `http://localhost/callback.html`. Keycloak client Valid Redirect URIs에 정확히 등록되어야 함 (sub-5-2 참조).
|
||||
- **silent renew**: refresh token rotation ON이면 매 갱신마다 새 refresh token. rotation 동작 검증은 sub-5-6에서.
|
||||
- **`scope=openid profile`**: `openid`는 OIDC 식별, `profile`은 `preferred_username` 등 user claim 포함.
|
||||
- **manual PKCE 학습 가치**: `code_challenge` 계산, state/nonce 관리, callback URL parsing을 직접 다뤄야 OIDC 흐름이 머리에 그려짐.
|
||||
|
||||
## 결정 사항 (decisions)
|
||||
|
||||
- 2026-05-25: **`oidc-client-ts` 우선, manual은 별도 단계.** 이유: 작동하는 환경을 먼저 만들고 내부 동작은 비교 학습.
|
||||
- 2026-05-25: **token storage in-memory (학습용).** 이유: localStorage XSS 우려 — prod에서는 BFF 패턴이 더 안전. 학습 단계에서 token 흐름이 명확히 보이도록 in-memory 채택.
|
||||
- 2026-05-25: **`automaticSilentRenew: true`.** 이유: refresh token rotation 동작 시연 (sub-5-6) 자동화.
|
||||
- 2026-05-25: **`response_type=code` 고정** (legacy `implicit` flow 미사용). 이유: RFC 8252 / OAuth 2.1 권장 — implicit flow는 deprecated.
|
||||
- 2026-05-25: **redirect_uri는 `http://localhost/callback.html` 단일**. 이유: callback page 분리 → main page 로딩 흐름과 분리해 디버깅 쉬움.
|
||||
- 2026-07-18 (`/branch-spec` 자동조사 보강): **D4 를 `UNSUPPORTED` 에서 해소** — implicit deprecation 의 공식 근거를 [[raw/official-docs/oauth-v2-1-draft-ietf]] `OA21-C2`(Implicit + ROPC grant 제거) + `OA21-C1`(PKCE MUST all clients)로 확정. 기존 RFC 8252 추정 대신 OAuth 2.1 표준 직접 인용.
|
||||
- 2026-07-18 (`/branch-spec` 자동조사 보강): **D5 를 `UNSUPPORTED` 에서 해소(부분)** — redirect_uri exact-match 요구는 `OA21-C5`(registered redirect URI 와 exact match 안 하면 MUST 거부)로 확정. 단 **단일 callback page 분리 vs main-page `?code=` 감지** 는 표준 요구가 아닌 디버깅 편의 판단이므로 `UNSUPPORTED_IMPL_DECISION`(§구현가이드 2)으로 강등.
|
||||
- 2026-07-18 (`/branch-spec` 자동조사 보강): **D2 를 `UNSUPPORTED` 에서 해소(위임)** — token 저장 위치 trade-off 는 owner sibling [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] (D1/D2/D6, OWASP·Curity·OAuth 2.1 근거)가 소유. 본 branch 는 그 분석을 재진술하지 않고 **학습 단계용 in-memory 지점**을 선택(선택 조건 = 학습 vs prod). Reference-Only(`rules/consistency-contract`).
|
||||
|
||||
## 결정-근거 매핑
|
||||
|
||||
> 각 결정이 어떤 raw source claim 으로 뒷받침되는지 명시한다.
|
||||
> `Decision ID` 는 이 branch-note 안에서 안정적으로 유지한다.
|
||||
> `Supporting Claims` 는 `raw/<category>/<slug>.md#<CLAIM-ID>` 형식.
|
||||
> `선택 조건` 열(R2): 이 조건일 때 이 결정 / 다른 조건이면 어떤 대안. 분기 없으면 N/A.
|
||||
|
||||
| Decision ID | Decision | 선택 조건 (언제 이 결정 / 언제 대안) | Supporting Claims | Evidence Strength | Open Risk |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | `oidc-client-ts` 우선 채택 (manual PKCE 는 비교 학습용 별도 단계) | 작동하는 baseline 을 먼저 확보하고 내부 동작을 비교 학습 → library 우선. 브라우저 내부 crypto/state/nonce 를 직접 다뤄 학습 → manual `crypto.subtle` 구현(§구현가이드 5) | `raw/official-docs/oidc-client-ts-library.md#OIDCTS-C3` (PKCE 지원 명시), `raw/official-docs/oidc-client-ts-library.md#OIDCTS-C2` (OAuth 2.1 지속 지원 protocol 만) | `official-vendor-doc` | OIDCTS-C1 이 origin project 2021-06 개발 중단을 명시 — fork 의 active maintenance / 보안 패치 상태는 별도 확인 |
|
||||
| D2 | pure SPA token storage = explicit in-memory (access/refresh 모두), reload 시 재인증 | AP1 pure SPA baseline이면 default store에 의존하지 않고 memory-only. HttpOnly refresh cookie는 [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] D7의 TMB/BFF variant | 위임: [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] D1/D2/D6 | `delegated (official-reference via owner)` | 실행 중 XSS 노출은 남는다. redirect transaction state와 token user store를 구분해야 함 |
|
||||
| D3 | `automaticSilentRenew: true` (refresh token rotation 자동화) | refresh token rotation 동작을 자동 시연하려는 학습 목표 → 활성. Keycloak **cross-site + Safari** 배포로 iframe silent renew 가 구조적으로 실패하는 환경 → refresh_token grant 직접 사용 우선(owner token-storage D3) | `raw/official-docs/oidc-client-ts-library.md#OIDCTS-C4` (Refresh Token Grant 지원), `raw/official-docs/oidc-client-ts-library.md#OIDCTS-C5` (Silent Refresh Token in iframe Flow 지원). 실패 조건 위임: [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] D3(Safari)·D3a(Chrome) | `official-vendor-doc` | OIDCTS-C5 의 "Does not prove": 3rd-party cookie 차단 환경(Safari ITP / Chrome Incognito)에서 iframe flow 보장 안 함. `automaticSilentRenew` 가 iframe vs refresh_token grant 중 무엇을 default 로 쓰는지 미확정(Claims To Verify). rotation default 활성은 Keycloak server-side 설정 의존 |
|
||||
| D4 | `response_type=code` 고정 (implicit flow 미사용) | public client(SPA)의 표준 flow → 항상 code + PKCE. implicit 은 OAuth 2.1 에서 제거되어 대안이 아님 | `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C2` (Implicit + ROPC grant 제거), `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C1` (PKCE MUST all clients), `raw/official-docs/oauth2-pkce-rfc-7636.md#PKCE-RFC7636-C1` (public client + code grant → interception → PKCE 전제) | `official-standard` | (이전 UNSUPPORTED 해소 — `OA21-C2` 가 implicit 제거를 직접 증명) `code_verifier` 길이/문자셋(RFC 7636 §4.1)은 본 인용 범위 밖 |
|
||||
| D5 | redirect_uri = `http://localhost/callback.html` 단일 (exact-match) | authorization server 는 registered redirect URI 와 exact match 안 하면 MUST 거부 → 정확한 단일 URI 등록. **callback 전용 page 분리 vs main page `?code=` 감지** 는 디버깅 편의 판단(임의) | `raw/official-docs/oauth-v2-1-draft-ietf.md#OA21-C5` (redirect URI exact-match MUST) | `official-standard` (exact-match 요구); callback page 분리는 `UNSUPPORTED_IMPL_DECISION`(§구현가이드 2) | exact-match 자체는 `OA21-C5` 로 증명. **단일 callback page 분리**는 표준 요구 아님 — main-page handling 도 유효. 등록된 redirect URI 실체는 owner sibling [[raw/branch-notes/feature-keycloak-realm-client-export]] 소유 → 그 등록값 변경 시 D5 영향 |
|
||||
|
||||
## 구현 가이드
|
||||
|
||||
> 본 branch 는 `documented-only`(실 구현 repo `keycloak-patterns/` 아직 부재 — `NO_GROUND_TRUTH`). 아래는 다음 구현자가 *되묻지 않고 코드를 작성할 수준*의 사전 명세. 각 sub-section 은 Decision ID + Supporting Claim ID 를 Trace 하거나 `UNSUPPORTED_IMPL_DECISION`/`OUT_OF_BRANCH_SCOPE` 라벨을 단다(CLAUDE.md §15.5 3-rule).
|
||||
|
||||
### 1. `UserManager` 설정 명세 (config object)
|
||||
|
||||
> **Trace**: D1 (`OIDCTS-C3` PKCE 자동), D3 (`OIDCTS-C4`/`C5` refresh·silent), D4 (`OA21-C1` PKCE MUST, `OA21-C2` implicit 제거, `PKCE-RFC7636-C1`), D5 (`OA21-C5` exact-match)
|
||||
>
|
||||
> - **OUT_OF_BRANCH_SCOPE**: `client_id`(`spa-client`) 값·Valid Redirect URIs 등록은 owner sibling [[raw/branch-notes/feature-keycloak-realm-client-export]] `#D2`(현재 학습용 `http://localhost/*`·`http://127.0.0.1/*` wildcard 등록), server-side PKCE method=S256 강제는 그 `#D1`(`KC-PKCE-C1`/`C3`) 소유. 본 branch 는 그 등록값을 *consume* 만 한다.
|
||||
|
||||
| config key | 값 | Trace | 라벨 |
|
||||
|---|---|---|---|
|
||||
| `authority` | `http://localhost:8080/realms/keycloak-patterns` | realm URL → `.well-known/openid-configuration` 자동 조회. **`iss` 검증 위해 hostname 이 `KC_HOSTNAME` 과 일치 필수**(`OIDCTS` docs 경고: `authority`↔`KC_HOSTNAME`) → iss-claim-hostname-mismatch `#D1` 의존 | consume (iss-claim `#D1`) |
|
||||
| `client_id` | `spa-client` | client 등록 owner | `OUT_OF_BRANCH_SCOPE` (realm-client-export `#D1`/`#D2`) |
|
||||
| `redirect_uri` | `http://localhost/callback.html` | D5 / `OA21-C5` (exact-match); 등록은 realm-client-export `#D2` | — |
|
||||
| `post_logout_redirect_uri` | `http://localhost/` | logout redirect | `UNSUPPORTED_IMPL_DECISION`: 루트 `/` 로 복귀는 임의 — trade-off: 전용 logged-out page 분리하면 UX 명확하나 파일 1개 추가 |
|
||||
| `response_type` | `code` | D4 / `OA21-C2`(implicit 제거)·`PKCE-RFC7636-C1` | — |
|
||||
| `scope` | `openid profile` | `openid`=OIDC 식별, `profile`=`preferred_username` claim | `UNSUPPORTED_IMPL_DECISION`: `profile` 외 scope(email/roles 등)는 /api/me 요구에 따라 — trade-off: 최소 scope 원칙 vs claim 부족 시 재요청 |
|
||||
| `automaticSilentRenew` | `true` | D3 / `OIDCTS-C4`/`C5` | — |
|
||||
|
||||
### 2. 페이지·이벤트 wiring 명세 (`index.html`
|
||||
|
||||
> **Trace**: D1 (library `signinRedirect`/`signinRedirectCallback`/`signoutRedirect`), D5 (callback URI)
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: (a) DOM element id 명명(`login`/`logout`/`result`)은 임의 — trade-off: 짧은 고정 id 는 단순하나 다중 위젯 시 충돌 위험. (b) **callback 전용 `callback.html` 분리 vs main page 에서 `?code=` 감지**는 D5 Open Risk 의 디버깅 편의 판단 — trade-off: 분리는 main 로딩 흐름과 격리돼 디버깅 쉽지만 redirect_uri·정적 파일 1개 추가; main-page handling 은 파일 최소이나 초기 로드 로직에 code 교환이 섞임.
|
||||
|
||||
| 대상 | 명세 |
|
||||
|---|---|
|
||||
| `index.html` | `<button id="login">`, `<button id="logout">`, `<pre id="result">` |
|
||||
| `app.js` (main load) | `userManager.getUser()` → user 있으면 §4 `/api/me` 호출; `#login`.onclick → `userManager.signinRedirect()`; `#logout`.onclick → `userManager.signoutRedirect()` |
|
||||
| `callback.html` | `<script>` → `new UserManager(config).signinRedirectCallback().then(() => location.href = '/')` (code→token 교환 후 main 복귀) |
|
||||
|
||||
### 3. explicit in-memory token store 명세
|
||||
|
||||
> **Trace**: D2 (위임 [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] D1/D2 — localStorage 회피, access=memory)
|
||||
>
|
||||
> pinned version default는 미검증이므로 active baseline은 default와 무관하게 explicit store를 지정한다.
|
||||
|
||||
- `userStore: new WebStorageStateStore({ store: new InMemoryWebStorage() })` — access/refresh/id token을 memory-only로 유지.
|
||||
- `stateStore: new WebStorageStateStore({ store: window.sessionStorage })` — full-page redirect의 `state`/transaction만 생존시키며 callback 성공 뒤 정리. token persistence 용도가 아니다.
|
||||
- reload 뒤 `getUser()`가 비면 silent 복구를 기본 가정하지 않고 재인증한다.
|
||||
|
||||
### 4. `/api/me` 호출 + silent renew 검증 명세
|
||||
|
||||
> **Trace**: D3 (silent renew), D1 (`user.access_token`)
|
||||
>
|
||||
> - **OUT_OF_BRANCH_SCOPE**: nginx 80 → backend 8081 의 CORS preflight 정책(Authorization 헤더 허용·credentials)은 backend Spring Security 결정 → RS 계열 [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] 로 위임. 본 branch 는 "Bearer 헤더로 호출한다"는 client 측 요구만 남긴다.
|
||||
|
||||
- static-only nginx/3-port topology에서는 `runtimeConfig.backendBaseUrl` 기본값 `http://localhost:8081`을 주입하고 `fetch(runtimeConfig.backendBaseUrl + '/api/me', ...)`로 호출한다. relative `/api/me`는 nginx `:80`로 가므로 사용하지 않는다.
|
||||
- silent renew 검증(§Claims To Verify): access token 만료 직전 DevTools Network 에서 `/token` (`grant_type=refresh_token`) 호출 vs hidden iframe 로드 관찰 → `automaticSilentRenew` 의 실제 메커니즘 확정.
|
||||
|
||||
### 5. (별도 학습 단계) manual PKCE 구현 명세
|
||||
|
||||
> **Trace**: `PKCE-RFC7636-C2`(verifier 생성·기록 + challenge 도출), `PKCE-RFC7636-C3`(`code_challenge = BASE64URL-ENCODE(SHA256(ASCII(verifier)))`), `PKCE-RFC7636-C4`(불일치 시 access 거부), verifier 저장 위치는 위임 [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] D6(full-page redirect 전제 → sessionStorage)
|
||||
>
|
||||
> - **UNSUPPORTED_IMPL_DECISION**: verifier sessionStorage 키명(`pkce_verifier`)은 임의 — trade-off: 고정키는 단순하나 multi-tab 동시 로그인 시 충돌(owner D6 는 `state` 포함 키를 대안 제시).
|
||||
|
||||
| 단계 | 명세 | Trace |
|
||||
|---|---|---|
|
||||
| verifier 생성 | `crypto.getRandomValues(new Uint8Array(32))` → base64url → `code_verifier` (43–128 char) | `PKCE-RFC7636-C2` |
|
||||
| challenge 도출 | `crypto.subtle.digest('SHA-256', TextEncoder().encode(verifier))` → base64url → `code_challenge`, `code_challenge_method=S256` | `PKCE-RFC7636-C3` |
|
||||
| verifier 보관 | `sessionStorage.setItem('pkce_verifier', verifier)` — 토큰 교환 성공 즉시 `removeItem` | owner token-storage D6 |
|
||||
| token 교환 | discovery metadata의 absolute `token_endpoint`로 POST. relative `/token` 금지 | `PKCE-RFC7636-C4` + static-only topology |
|
||||
|
||||
## 엣지·실패·의존
|
||||
|
||||
> R4(깊이 게이트) 캡처용. 정상 경로 외 실패/엣지/다른 계약 의존.
|
||||
|
||||
- **실패·엣지 경로**:
|
||||
- **redirect_uri mismatch** (`localhost` vs `127.0.0.1`, 또는 `/callback.html` 오타): authorization server 가 exact-match 실패로 요청 거부(D5 / `OA21-C5`) → authorize 단계에서 에러. 등록값은 owner realm-client-export 소유.
|
||||
- **CORS preflight 실패**: nginx 80 → backend 8081 의 `/api/me` 호출 시 backend CORS 미설정이면 preflight(OPTIONS) 차단 → §구현가이드 4 OUT_OF_BRANCH_SCOPE(RS branch).
|
||||
- **silent renew 실패**: Keycloak **cross-site + Safari ITP** → hidden iframe 이 SSO cookie 못 읽음 → 어댑터가 full redirect fallback("silent" 상실). Chrome 일반 모드는 현재 동작(owner D3a)하나 정책 변동 리스크. → refresh_token grant 직접 사용으로 우회(owner token-storage D3).
|
||||
- **in-memory 토큰 reload 소실**: 페이지 새로고침 시 access/refresh token이 함께 소멸 → baseline은 재인증. silent SSO는 별도 조건부 비교다.
|
||||
- **manual PKCE verifier 소실**: full-page redirect 가 메모리 verifier 파괴 → sessionStorage 필수(owner D6). 콜백에서 verifier 부재 시 token 교환 실패(`PKCE-RFC7636-C4`: "Access is denied if they are not equal").
|
||||
- **access token 만료 vs API 호출 race**: `/api/me` 호출 순간 토큰 만료면 401 → silent renew 후 재시도 필요(구현 시 retry wrapper 고려, `needs-confirmation`).
|
||||
- **다른 계약 의존**:
|
||||
- [[raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff]] `D1`(access=memory)·`D2`(localStorage 금지)·`D6`(verifier=sessionStorage) — 본 branch 의 token/verifier 저장 배치는 이 owner 의 trade-off 분석을 consume. 그 결정이 바뀌면(예: prod 에서 httpOnly cookie 필수화) 본 branch 저장 명세 재검토.
|
||||
- [[raw/branch-notes/feature-keycloak-realm-client-export]] `#D1`(server-side PKCE method=S256 강제, `KC-PKCE-C1`/`C3`)·`#D2`(Valid Redirect URIs 등록 — 현재 학습용 `http://localhost/*`·`127.0.0.1/*` wildcard) 를 owns. 등록된 redirect URI 가 바뀌면 D5·§구현가이드 1 영향.
|
||||
- [[raw/branch-notes/feature-keycloak-iss-claim-hostname-mismatch]] `#D1`(`KC_HOSTNAME=localhost` 로 issuer URL 고정) — 본 branch `authority` hostname(`localhost`)이 이 값과 일치해야 발급 token 의 `iss` 가 backend RS 검증을 통과(`OIDCTS` docs: `authority`↔`KC_HOSTNAME` 일치 경고). 불일치 시 `/api/me` 가 401 → 원인이 CORS(§구현가이드 4)가 아니라 `iss` mismatch 임을 구분해 진단.
|
||||
- [[raw/branch-notes/feature-keycloak-refresh-rotation-and-logout]] (및 [[raw/branch-notes/feature-keycloak-refresh-token-rotation]]) — `automaticSilentRenew` 가 refresh_token grant 로 동작 시 rotation 계약(재사용 탐지·TTL)에 의존. rotation 활성/family invalidate 범위가 바뀌면 D3 갱신 동작 영향.
|
||||
- [[raw/branch-notes/feature-keycloak-spring-rs-audience-validator]] — `/api/me` 의 Bearer 검증·CORS·audience 정책을 owns(§구현가이드 4 OUT_OF_BRANCH_SCOPE).
|
||||
- [[raw/branch-notes/feature-keycloak-pkce-flow-stages]] — PKCE (A)~(E) 단계 분해를 owns. 본 branch §구현가이드 5 는 그 단계 설계의 vanilla-JS 구현.
|
||||
|
||||
## 검증해야 할 주장
|
||||
|
||||
> 공식 문서 근거가 있어도 내 프로젝트에서의 동작을 자동으로 보장하지 않는다.
|
||||
|
||||
| Claim | Why uncertain | How to verify | Status |
|
||||
|---|---|---|---|
|
||||
| `oidc-client-ts` 의 `automaticSilentRenew: true` 가 iframe 기반이 아닌 refresh token grant 로 동작한다 | OIDCTS-C4 와 C5 가 둘 다 지원 protocol 로 나열됨 — 어느 메커니즘이 default 인지 본 인용 범위 밖 | dev 환경에서 token 만료 직전 DevTools Network 탭 캡처 → `/token` (`grant_type=refresh_token`) 호출 확인 vs iframe 로드 확인 | `planned` |
|
||||
| Keycloak SPA client 의 access token 만료가 5분이며 그 직전에 silent renew 트리거 | 본 branch 의 Sources 는 Keycloak 의 token lifetime default 를 다루지 않음 | dev Keycloak realm settings > Tokens > Access Token Lifespan 확인 | `planned` |
|
||||
| `crypto.subtle.digest('SHA-256', ...)` + base64url 로 manual PKCE 구현이 oidc-client-ts 와 동일한 challenge 값 생성 | RFC 7636 PKCE-RFC7636-C3 가 `BASE64URL-ENCODE(SHA256(ASCII(verifier)))` 공식 정의. 두 구현의 byte-level 일치는 실측 필요 | 동일 verifier 입력으로 manual 함수와 oidc-client-ts 내부 함수 결과 비교 | `planned` |
|
||||
| nginx 80 → backend 8081 CORS preflight 통과 (Authorization 헤더 허용 + credentials 정책) | 본 branch 의 Sources 는 CORS 정책을 다루지 않음 (RS branch 소유) | backend Spring Security CORS 설정 + DevTools Network preflight 응답 확인 | `planned` |
|
||||
| explicit in-memory userStore가 access/refresh token을 persistent storage에 남기지 않고 reload 뒤 재인증을 요구 | active baseline은 정했지만 pinned version runtime 미검증 | 로그인 후 local/sessionStorage token 검색 → reload 뒤 `getUser()` null → 재인증 E2E | `planned` |
|
||||
| Keycloak client "PKCE method"=S256 토글이 `code_challenge_method=plain` 요청을 실제로 거부한다 | `KC-PKCE-C3` 의 "applies... S256" 은 강제를 암시할 뿐 reject/error 를 명시 안 함(그 raw 의 Usage Boundaries) — server-side 강제는 realm-client-export owns | dev Keycloak 에서 PKCE method=S256 설정 후 plain 요청 → redirect 에러 파라미터/HTTP status 확인 | `needs-confirmation` |
|
||||
| pinned oidc-client-ts의 default `stateStore`/`userStore` 종류와 차이 | active baseline은 explicit store라 default에 의존하지 않지만 비교 설명의 사실 정확성은 미확인 | pinned version docs와 runtime storage key를 각각 확인 | `needs-confirmation` |
|
||||
|
||||
## 마주친 문제
|
||||
|
||||
- (구현 시작 후 추가) `localhost` vs `127.0.0.1` redirect_uri mismatch 예상.
|
||||
- (구현 시작 후 추가) CORS preflight 실패 예상 (backend CORS 설정 누락 시).
|
||||
- (구현 시작 후 추가) silent renew가 iframe 기반이면 third-party cookie 차단 이슈 — refresh token 기반인지 확인.
|
||||
|
||||
## 묶음
|
||||
|
||||
<!-- GENERATED: sources:start -->
|
||||
- [[raw/official-docs/keycloak-client-pkce-method-enforcement-official]]
|
||||
- [[raw/official-docs/oauth-v2-1-draft-ietf]]
|
||||
- [[raw/official-docs/oauth2-pkce-rfc-7636]]
|
||||
- [[raw/official-docs/oidc-client-ts-library]]
|
||||
- [[raw/official-docs/owasp-html5-storage-xss-spa]]
|
||||
<!-- GENERATED: sources:end -->
|
||||
|
||||
> 본 sub-sub-branch 는 leaf — 자식 자료 없음. Phase 3 P3A 실 구현 또는 외부 산출물 단계에서 errors / interview prep / lectures 가 누적되면 본 섹션에서 그룹화.
|
||||
|
||||
### 오류 기록 (이 sub-sub-branch 작업 중 발생)
|
||||
|
||||
- (없음 — 현재 documented-only 단계)
|
||||
|
||||
### 면접 준비 (이 작업에서 나올 수 있는 면접 질문)
|
||||
|
||||
- (없음 — Phase 3 실 구현 단계에 누적)
|
||||
|
||||
## 관련 일일 노트
|
||||
|
||||
|
||||
## 완료 후 정리
|
||||
|
||||
> 로컬 검증(login → /api/me → silent renew → logout 전체 흐름) 통과 시 `planned` → `actually-implemented`/`locally-verified` 승급.
|
||||
|
||||
- PR 링크: (별도 keycloak-patterns repo)
|
||||
- 리뷰 메모:
|
||||
- 머지 결과 / 배포 환경: 로컬 docker-compose / 단일 EC2 시뮬레이션
|
||||
- **wiki 추출 대상**:
|
||||
- `actually-implemented` 항목: (구현 후 채움)
|
||||
- `locally-verified` 항목: (구현 후 채움)
|
||||
- `prod-verified` 항목: (없음)
|
||||
- **추출하지 않을 항목**: 현재 전부 `planned`.
|
||||
Reference in New Issue
Block a user