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