Files
llm-wiki/raw/branch-notes/feature-keycloak-vanilla-js-spa-pkce.md
T

306 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: branch / feature-keycloak-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` 43128자 랜덤 생성, `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` (43128 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`.