306 lines
28 KiB
Markdown
306 lines
28 KiB
Markdown
---
|
||
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`.
|