Files
llm-wiki/raw/branch-notes/feature-keycloak-spa-token-storage-tradeoff.md
T

307 lines
30 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-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` 유지.