refactor: 문서 개선 중

This commit is contained in:
donghyeon-ka
2026-09-21 14:30:55 +09:00
parent c93cdea150
commit 805a18f486
1497 changed files with 525837 additions and 59152 deletions
@@ -0,0 +1,82 @@
# Keycloak 인증 패턴 기록
Tech Log Studio에 있는 18건이다. **Studio의 working copy가 정본이고**, 이 폴더의 세 형식은
모두 그 값을 받아 적은 것이다.
| 파일 | 무엇 |
|---|---|
| `<name>.md` | 사람이 읽고 고치는 형식. 여기서 고친 뒤 Studio로 올린다 |
| `<name>.json` | 같은 내용의 기계 판독 형식. Studio에서 받아 다시 만든다 |
| `case-*.body.md` | Case 본문만 따로 뺀 것. 파서 검사에 쓴다 |
셋은 같이 갱신한다. 하나만 고치면 다음 사람이 어느 쪽이 최신인지 알 수 없다.
마지막 동기화는 2026-08-26이고 그 시점에 세 형식과 Studio가 모두 같았다.
## 파일 형식
| 부분 | 담는 것 |
|---|---|
| front matter | id, kind, slug, title, topic, project, status, version, 검증일, Studio·공개 주소 |
| `#` 제목 다음 문단 | 요약 |
| `## 관계` · `## 근거` | 대상 제목과 이유. Decision만 「근거」다 |
| `## 규칙` · `## 선택지` | `### N. 제목` 다음에 본문 |
| 목록 칸 | `-` 항목. 적용 조건·예외·예시·사실·가정·미지수·제약·영향 |
| `## 본문` | Case만. `<!-- body:start -->``<!-- body:end -->` 사이가 Studio 본문 원문이다 |
Case 본문은 글자 단위로 Studio 값과 같다. Reference·Question·Decision의 칸은 평문으로
렌더링되므로 표와 코드블록을 넣지 않는다. 비교 축이 필요하면 `이름 : 값` 줄로 쓴다.
다이어그램 SVG와 record JSON은 같은 폴더에 있다. `relation-plan.json`은 18개 문서의 관계를
한 파일로 모은 색인이고 `.md`에서 다시 만든다.
## 목록
### Case (4건)
| 파일 | 제목 | 상태 | 버전 |
|---|---|---|---|
| [case-ap2-split-custody.md](case-ap2-split-custody.md) | Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조 | 게시 중 [공개](https://hyeonworks.com/cases/split-custody-access-token) | v20 |
| [case-ap3-bff-session-csrf.md](case-ap3-bff-session-csrf.md) | BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정 | 게시 중 [공개](https://hyeonworks.com/cases/bff-session-csrf-responsibility) | v28 |
| [case-ap4-identity-header-trust.md](case-ap4-identity-header-trust.md) | Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유 | 게시 중 [공개](https://hyeonworks.com/cases/identity-header-trust) | v37 |
| [case-browser-credential-boundary.md](case-browser-credential-boundary.md) | SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계 | 게시 중 [공개](https://hyeonworks.com/cases/spa-browser-credential-boundary) | v25 |
### Reference (7건)
| 파일 | 제목 | 상태 | 버전 |
|---|---|---|---|
| [reference-authorization-code-endpoints.md](reference-authorization-code-endpoints.md) | Authorization Code Flow의 Endpoint와 Credential 이동 기준 | 게시 중 [공개](https://hyeonworks.com/references/authorization-code-endpoint-credential-movement) | v32 |
| [reference-bff-auth-design.md](reference-bff-auth-design.md) | BFF 인증 구조 설계 기준 | 게시 전 | v10 |
| [reference-forward-auth-header-trust.md](reference-forward-auth-header-trust.md) | Forward-Auth에서 Identity Header를 신뢰하기 위한 조건 | 게시 전 | v10 |
| [reference-idp-federation-boundary.md](reference-idp-federation-boundary.md) | 외부 IdP Federation과 Application 인증 경계 | 게시 전 | v9 |
| [reference-pattern-selection.md](reference-pattern-selection.md) | OAuth/OIDC 인증 패턴 선택 기준 | 게시 전 | v10 |
| [reference-public-confidential-client.md](reference-public-confidential-client.md) | Public Client와 Confidential Client 구분 기준 | 게시 전 | v12 |
| [reference-token-vs-session.md](reference-token-vs-session.md) | OAuth Token과 Application Session을 구분하는 기준 | 게시 전 | v11 |
### Question (4건)
| 파일 | 제목 | 상태 | 버전 |
|---|---|---|---|
| [question-bff-state-store.md](question-bff-state-store.md) | BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가 | 게시 전 | v8 |
| [question-edge-authorization-scope.md](question-edge-authorization-scope.md) | Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가 | 게시 전 | v9 |
| [question-multi-instance-session.md](question-multi-instance-session.md) | 서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가 | 게시 전 | v10 |
| [question-refresh-rotation-replica.md](question-refresh-rotation-replica.md) | Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가 | 게시 전 | v10 |
### Decision (3건)
| 파일 | 제목 | 상태 | 버전 |
|---|---|---|---|
| [decision-bff-owns-token.md](decision-bff-owns-token.md) | BFF가 OAuth Token을 관리하는 조건 | 게시 전 | v11 |
| [decision-federation-not-a-pattern.md](decision-federation-not-a-pattern.md) | 외부 IdP Federation을 별도의 인증 구조로 세지 않는다 | 게시 전 | v9 |
| [decision-not-maturity-ladder.md](decision-not-maturity-ladder.md) | 인증 구조를 보안 성숙도 단계로 취급하지 않는다 | 게시 전 | v11 |
## 주의
**이미 게시된 문서는 Studio에서 저장하는 순간 공개 화면에 반영된다.** 게시 기록에 새 이벤트가
남지 않아도 그렇다. 2026-08-25에 확인했다. 게시된 문서를 고칠 때는 먼저 Studio의 현재 값을
여기로 받아 온 다음 고친다. 로컬 파일이 오래됐으면 Studio에서 손댄 내용을 덮어쓰게 된다.
Studio 편집기에는 working copy 버전 이력이 없다. 게시 기록에서 볼 수 있는 것은 게시 시점의
Snapshot뿐이다.
문체 기준은 `.claude/skills/writing-tech-log-records`에 있다. 문서군 전체의 리듬은
`references/ai-tells.md`.
@@ -0,0 +1,58 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 760 280" role="img"
aria-labelledby="t d">
<title id="t">AP1 credential 보관 경계</title>
<desc id="d">브라우저 실행 영역 하나가 code 교환, token 보관, 요청 서명 세 가지를 모두 담고 있고, 그 영역 전체가 실행 중 XSS가 닿는 범위다. Keycloak과 Resource Server는 그 밖에 있으며 Resource Server는 서명·issuer·audience를 검증한다.</desc>
<style>
.lbl { font: 13px system-ui, -apple-system, "Segoe UI", sans-serif; fill: #17181b; }
.sub { font: 11px system-ui, -apple-system, sans-serif; fill: #5b6068; }
.zone { font: 600 11px system-ui, -apple-system, sans-serif; letter-spacing: .06em; }
.box { fill: #fff; stroke: #b9bdc4; stroke-width: 1; }
.arw { stroke: #6b7079; stroke-width: 1.4; fill: none; marker-end: url(#h); }
</style>
<defs>
<marker id="h" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#6b7079"/>
</marker>
<pattern id="x" width="7" height="7" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
<line x1="0" y1="0" x2="0" y2="7" stroke="#c2695c" stroke-width="1" opacity=".28"/>
</pattern>
</defs>
<!-- XSS reach: hatch + border, so it reads without colour -->
<rect x="20" y="52" width="392" height="208" rx="7" fill="url(#x)" stroke="#c2695c" stroke-width="1.5"/>
<text x="20" y="42" class="zone" fill="#a8483c">실행 중 XSS가 닿는 범위</text>
<rect x="34" y="66" width="364" height="180" rx="5" class="box"/>
<text x="48" y="88" class="zone" fill="#5b6068">브라우저</text>
<rect x="50" y="100" width="332" height="40" rx="4" class="box"/>
<text x="64" y="119" class="lbl">code 교환</text>
<text x="64" y="134" class="sub">code_verifier</text>
<rect x="50" y="150" width="332" height="40" rx="4" class="box"/>
<text x="64" y="169" class="lbl">token 보관</text>
<text x="64" y="184" class="sub">access · refresh · ID — JavaScript memory</text>
<rect x="50" y="200" width="332" height="34" rx="4" class="box"/>
<text x="64" y="222" class="lbl">요청 서명</text>
<text x="150" y="222" class="sub">Authorization: Bearer</text>
<rect x="468" y="52" width="272" height="62" rx="5" class="box"/>
<text x="484" y="74" class="zone" fill="#5b6068">KEYCLOAK</text>
<text x="484" y="98" class="lbl">Authorization Code + PKCE</text>
<rect x="468" y="152" width="272" height="108" rx="5" class="box"/>
<text x="484" y="174" class="zone" fill="#5b6068">RESOURCE SERVER</text>
<text x="484" y="198" class="lbl">검증</text>
<text x="530" y="198" class="sub">서명 · issuer · audience</text>
<text x="484" y="222" class="lbl">STATELESS</text>
<text x="484" y="243" class="sub">지울 session이 없다</text>
<path class="arw" d="M412 90 H464"/>
<text x="418" y="82" class="sub">code</text>
<path class="arw" d="M412 216 H464"/>
<text x="418" y="208" class="sub">Bearer</text>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,58 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 820 280" role="img"
aria-labelledby="t d">
<title id="t">AP2 split custody 경계</title>
<desc id="d">Spring mediator가 authorized client에 access token과 refresh token을 함께 보관하지만, access token만 브라우저 실행 영역으로 돌아온다. 브라우저는 그 값으로 Authorization 헤더를 만들어 Resource Server를 직접 호출하며 이 경로는 mediator를 지나지 않는다. 브라우저 실행 영역 전체가 실행 중 XSS가 닿는 범위다.</desc>
<style>
.lbl { font: 13px system-ui, -apple-system, "Segoe UI", sans-serif; fill: #17181b; }
.sub { font: 11px system-ui, -apple-system, sans-serif; fill: #5b6068; }
.zone { font: 600 11px system-ui, -apple-system, sans-serif; letter-spacing: .06em; }
.box { fill: #fff; stroke: #b9bdc4; stroke-width: 1; }
.arw { stroke: #6b7079; stroke-width: 1.4; fill: none; marker-end: url(#h); }
</style>
<defs>
<marker id="h" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#6b7079"/>
</marker>
<pattern id="x" width="7" height="7" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
<line x1="0" y1="0" x2="0" y2="7" stroke="#c2695c" stroke-width="1" opacity=".28"/>
</pattern>
</defs>
<!-- XSS reach: hatch + border, so it reads without colour -->
<rect x="16" y="52" width="296" height="170" rx="7" fill="url(#x)" stroke="#c2695c" stroke-width="1.5"/>
<text x="16" y="42" class="zone" fill="#a8483c">실행 중 XSS가 닿는 범위</text>
<rect x="28" y="66" width="272" height="142" rx="5" class="box"/>
<text x="42" y="88" class="zone" fill="#5b6068">브라우저</text>
<rect x="42" y="100" width="244" height="44" rx="4" class="box"/>
<text x="56" y="120" class="lbl">AP2_SESSION</text>
<text x="56" y="136" class="sub">HttpOnly · SameSite=Lax</text>
<rect x="42" y="152" width="244" height="44" rx="4" class="box"/>
<text x="56" y="172" class="lbl">access token</text>
<text x="56" y="188" class="sub">JavaScript 지역 변수</text>
<rect x="392" y="52" width="224" height="156" rx="5" class="box"/>
<text x="404" y="74" class="zone" fill="#5b6068">SPRING MEDIATOR</text>
<text x="404" y="92" class="sub">confidential · client_secret_basic</text>
<rect x="404" y="106" width="200" height="44" rx="4" class="box"/>
<text x="416" y="126" class="lbl">authorized client</text>
<text x="416" y="142" class="sub">access · refresh</text>
<rect x="656" y="100" width="152" height="96" rx="5" class="box"/>
<text x="668" y="122" class="zone" fill="#5b6068">RESOURCE SERVER</text>
<text x="668" y="148" class="lbl">검증</text>
<text x="668" y="166" class="sub">서명 · issuer · audience</text>
<path class="arw" d="M314 122 H388"/>
<text x="316" y="114" class="sub">/token/access</text>
<path class="arw" d="M388 174 H316"/>
<path class="arw" d="M160 224 V258 H732 V200"/>
<text x="380" y="252" class="sub">Authorization: Bearer</text>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,62 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 285" role="img"
aria-labelledby="t d">
<title id="t">AP3 BFF custody 경계</title>
<desc id="d">브라우저에는 HttpOnly AP3_SESSION과 JavaScript가 읽을 수 있는 XSRF-TOKEN만 있고 OAuth token은 없다. BFF가 authorized client에서 access token과 refresh token을 들고 있으며, Resource Server로 가는 Bearer 요청은 BFF에서 새로 만들어진다. 브라우저의 session cookie는 downstream으로 전달되지 않는다. 브라우저 실행 영역 전체가 실행 중 XSS가 닿는 범위다.</desc>
<style>
.lbl { font: 13px system-ui, -apple-system, "Segoe UI", sans-serif; fill: #17181b; }
.sub { font: 11px system-ui, -apple-system, sans-serif; fill: #5b6068; }
.zone { font: 600 11px system-ui, -apple-system, sans-serif; letter-spacing: .06em; }
.box { fill: #fff; stroke: #b9bdc4; stroke-width: 1; }
.gone { fill: #fff; stroke: #b9bdc4; stroke-width: 1; stroke-dasharray: 4 3; }
.arw { stroke: #6b7079; stroke-width: 1.4; fill: none; marker-end: url(#h); }
</style>
<defs>
<marker id="h" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#6b7079"/>
</marker>
<pattern id="x" width="7" height="7" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
<line x1="0" y1="0" x2="0" y2="7" stroke="#c2695c" stroke-width="1" opacity=".28"/>
</pattern>
</defs>
<rect x="16" y="52" width="270" height="190" rx="7" fill="url(#x)" stroke="#c2695c" stroke-width="1.5"/>
<text x="16" y="42" class="zone" fill="#a8483c">실행 중 XSS가 닿는 범위</text>
<rect x="28" y="66" width="246" height="164" rx="5" class="box"/>
<text x="42" y="88" class="zone" fill="#5b6068">브라우저</text>
<rect x="42" y="100" width="218" height="42" rx="4" class="box"/>
<text x="56" y="119" class="lbl">AP3_SESSION</text>
<text x="56" y="134" class="sub">HttpOnly · JavaScript 읽기 x</text>
<rect x="42" y="150" width="218" height="42" rx="4" class="box"/>
<text x="56" y="169" class="lbl">XSRF-TOKEN</text>
<text x="56" y="184" class="sub">JavaScript 읽기 o</text>
<rect x="42" y="198" width="218" height="26" rx="4" class="gone"/>
<text x="56" y="215" class="lbl">OAuth token</text>
<text x="240" y="215" class="lbl">x</text>
<rect x="350" y="52" width="230" height="170" rx="5" class="box"/>
<text x="362" y="74" class="zone" fill="#5b6068">BFF</text>
<text x="362" y="92" class="sub">confidential · client_secret_basic</text>
<rect x="362" y="106" width="206" height="42" rx="4" class="box"/>
<text x="374" y="125" class="lbl">authorized client</text>
<text x="374" y="140" class="sub">access · refresh</text>
<rect x="636" y="100" width="150" height="90" rx="5" class="box"/>
<text x="648" y="122" class="zone" fill="#5b6068">RESOURCE SERVER</text>
<text x="648" y="148" class="lbl">검증</text>
<text x="648" y="166" class="sub">서명 · issuer · audience</text>
<path class="arw" d="M288 122 H346"/>
<text x="290" y="114" class="sub">/bff/api/me</text>
<path class="arw" d="M346 174 H290"/>
<path class="arw" d="M465 224 V260 H711 V194"/>
<text x="500" y="254" class="sub">Authorization: Bearer</text>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -0,0 +1,48 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 790 250" role="img"
aria-labelledby="t2 d2">
<title id="t2">AP3 CSRF token 두 갈래</title>
<desc id="d2">BFF의 CSRF endpoint 하나가 두 결과를 만든다. XSRF-TOKEN cookie에는 raw token이 들어가고 JSON 응답 본문에는 XOR로 가린 token과 headerName이 들어간다. SPA는 JSON에서 headerName만 읽고 실제 header 값은 cookie의 raw token을 쓴다. POST에 도달한 cookie와 header를 Spring CSRF filter가 대조한다.</desc>
<style>
.lbl { font: 13px system-ui, -apple-system, "Segoe UI", sans-serif; fill: #17181b; }
.sub { font: 11px system-ui, -apple-system, sans-serif; fill: #5b6068; }
.zone { font: 600 11px system-ui, -apple-system, sans-serif; letter-spacing: .06em; }
.box { fill: #fff; stroke: #b9bdc4; stroke-width: 1; }
.arw { stroke: #6b7079; stroke-width: 1.4; fill: none; marker-end: url(#h2); }
</style>
<defs>
<marker id="h2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#6b7079"/>
</marker>
</defs>
<rect x="16" y="92" width="136" height="60" rx="5" class="box"/>
<text x="30" y="116" class="lbl">/bff/csrf</text>
<text x="30" y="134" class="sub">GET</text>
<rect x="212" y="30" width="200" height="56" rx="5" class="box"/>
<text x="226" y="54" class="lbl">XSRF-TOKEN</text>
<text x="226" y="72" class="sub">cookie · raw token</text>
<rect x="212" y="158" width="200" height="56" rx="5" class="box"/>
<text x="226" y="182" class="lbl">JSON body</text>
<text x="226" y="200" class="sub">masked token · headerName</text>
<rect x="466" y="92" width="152" height="60" rx="5" class="box"/>
<text x="480" y="116" class="lbl">X-XSRF-TOKEN</text>
<text x="480" y="134" class="sub">= raw token</text>
<rect x="666" y="92" width="112" height="60" rx="5" class="box"/>
<text x="678" y="116" class="zone" fill="#5b6068">CSRF FILTER</text>
<text x="678" y="134" class="lbl">대조</text>
<path class="arw" d="M154 112 H182 V58 H208"/>
<path class="arw" d="M154 132 H182 V186 H208"/>
<path class="arw" d="M414 58 H440 V112 H462"/>
<path class="arw" d="M414 186 H440 V132 H462"/>
<text x="418" y="150" class="sub">headerName</text>
<path class="arw" d="M620 122 H662"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1,66 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 820 285" role="img"
aria-labelledby="t4 d4">
<title id="t4">AP4 edge 신뢰 경계</title>
<desc id="d4">브라우저는 AP4_SESSION과 함께 client가 만든 identity header도 보낼 수 있지만 그 header는 Nginx에서 덮어써진다. Nginx는 oauth2-proxy의 internal auth endpoint에 subrequest를 보내 user와 email을 받고, 그 값과 자신이 가진 internal token으로 upstream 요청을 새로 만든다. oauth2-proxy와 Spring upstream은 host port가 닫혀 있어 외부에서 직접 닿을 수 없다.</desc>
<style>
.lbl { font: 13px system-ui, -apple-system, "Segoe UI", sans-serif; fill: #17181b; }
.sub { font: 11px system-ui, -apple-system, sans-serif; fill: #5b6068; }
.zone { font: 600 11px system-ui, -apple-system, sans-serif; letter-spacing: .06em; }
.box { fill: #fff; stroke: #b9bdc4; stroke-width: 1; }
.gone { fill: #fff; stroke: #b9bdc4; stroke-width: 1; stroke-dasharray: 4 3; }
.arw { stroke: #6b7079; stroke-width: 1.4; fill: none; marker-end: url(#h4); }
</style>
<defs>
<marker id="h4" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#6b7079"/>
</marker>
<pattern id="x4" width="7" height="7" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
<line x1="0" y1="0" x2="0" y2="7" stroke="#c2695c" stroke-width="1" opacity=".28"/>
</pattern>
</defs>
<rect x="16" y="52" width="210" height="150" rx="7" fill="url(#x4)" stroke="#c2695c" stroke-width="1.5"/>
<text x="16" y="42" class="zone" fill="#a8483c">외부 · 신뢰하지 않는 입력</text>
<rect x="28" y="66" width="186" height="124" rx="5" class="box"/>
<text x="42" y="88" class="zone" fill="#5b6068">브라우저</text>
<rect x="42" y="100" width="158" height="38" rx="4" class="box"/>
<text x="54" y="118" class="lbl">AP4_SESSION</text>
<text x="54" y="132" class="sub">HttpOnly · Lax</text>
<rect x="42" y="144" width="158" height="38" rx="4" class="gone"/>
<text x="54" y="162" class="lbl">client 제공 header</text>
<text x="54" y="176" class="sub">덮어쓰기 대상</text>
<rect x="280" y="52" width="150" height="150" rx="5" class="box"/>
<text x="292" y="74" class="zone" fill="#5b6068">NGINX</text>
<text x="292" y="92" class="sub">8088 공개</text>
<text x="292" y="122" class="lbl">header 덮어쓰기</text>
<text x="292" y="140" class="sub">trusted proxy</text>
<rect x="490" y="40" width="316" height="210" rx="7" class="gone"/>
<text x="640" y="30" class="zone" fill="#5b6068">HOST PORT 닫힘</text>
<rect x="504" y="56" width="288" height="64" rx="5" class="box"/>
<text x="518" y="80" class="lbl">oauth2-proxy</text>
<text x="518" y="98" class="sub">internal /oauth2/auth</text>
<rect x="504" y="160" width="288" height="76" rx="5" class="box"/>
<text x="518" y="182" class="zone" fill="#5b6068">SPRING UPSTREAM</text>
<text x="518" y="204" class="lbl">/edge/me</text>
<text x="518" y="222" class="sub">user header + internal token</text>
<path class="arw" d="M228 122 H276"/>
<path class="arw" d="M432 80 H500"/>
<text x="436" y="72" class="sub">auth_request</text>
<path class="arw" d="M500 104 H434"/>
<text x="436" y="118" class="sub">user · email</text>
<path class="arw" d="M355 204 V262 H648 V240"/>
<text x="390" y="256" class="sub">nginx-owned header · internal token</text>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -0,0 +1,159 @@
## 토큰 관리 경계가 나뉘는 지점
:::evidence key="ap2-split-custody-779cb791" alt="Spring mediator의 authorized client 안에 access token과 refresh token이 함께 있고, 그중 access token만 브라우저 실행 영역으로 돌아오는 그림. 브라우저에서 Resource Server로 가는 Authorization Bearer 화살표는 mediator를 지나지 않는다. 브라우저 실행 영역 전체가 실행 중 XSS가 닿는 범위로 표시돼 있다." caption="" zoom="true"
:::
mediator는 access token과 refresh token을 모두 보관한다. 다만 브라우저가 Resource Server를 직접 호출해야 해서, access token은 `/token/access`를 통해 다시 브라우저로 전달된다.
## Mediator가 담당하는 OAuth 처리
SPA 구조에서는 브라우저가 authorization code를 직접 token으로 교환한다. Mediator 구조에서는 Spring backend가 confidential client로 등록되어 code 교환과 authorized client 저장을 처리한다.
SPA와 Mediator에서 각 동작을 수행하는 주체는 다음과 같다.
| 무엇 | 브라우저에 있나 | 서버에 있나 |
|---|---|---|
| client secret | x | o |
| refresh token | x | o |
| access token | o | o |
| 로그인 상태 | AP2_SESSION | HttpSession |
세 번째 줄이 이 Case의 관측이다. access token은 양쪽에 있다.
## AP2_SESSION이 생성되는 시점
`AP2_SESSION`이 token 교환을 마친 뒤에 발급된다고 읽기 쉽지만 그렇지 않다.
Spring Security는 로그인을 시작할 때 authorization request와 `state`를 HttpSession에 저장하고, 그 transaction을 찾기 위한 cookie를 먼저 발급한다. Browser가 KeyCloak으로 이동했다가 다시 Spring으로 다시 돌아왔을 때, Spring이 이 사용자가 아까 시작했던 로그인 요청이 무었이었는지 찾을 수 있어야 하기 때문에 로그인 시작 시점에 session 쿠키를 먼저 만들게 된다.
```text label="callback 하나가 두 개의 상태로 나뉜다"
AP2_SESSION
→ servlet HttpSession의 login SecurityContext
→ Authentication(principal name = preferred_username)
("keycloak", principal name)
→ OAuth2AuthorizedClientService
→ access token + refresh token
```
cookie가 token을 직렬화해 담고 있는 것이 아니다. cookie는 HttpSession을 식별하는 세션 ID이고, 그 HttpSession 안에 로그인 SecurityContext가 저장되어 있다. token은 이 cookie session에 담겨져 있는 principal을 가지고 별도 store에서 관리되고 있는 token을 찾는 것이다.
:::warning
`OAuth2AuthorizedClientService` 은 Spring Boot 자동구성이 고르는 in-memory 구현이고 Spring Session·Redis·JDBC token store 의존성도 없다. 로그인 상태와 token 상태가 **둘 다** process-local memory에 있다.
:::
## /token/access가 반환하는 세 가지 field
브라우저가 API를 호출하려면 access token이 필요하다. mediator는 이 endpoint로 반환하게 된다.
```http label="브라우저 입력 — cookie 한 개"
GET http://localhost:8082/token/access
Accept: application/json
Cookie: AP2_SESSION=<opaque-session-id>
```
controller는 `OAuth2AuthorizeRequest.withClientRegistrationId("keycloak")`을 만들고 현재 `Authentication`을 principal로 넣어 `OAuth2AuthorizedClientManager.authorize()`를 부른다. 돌아온 authorized client에서 access token만 꺼내 세 field로 만든다.
```http label="응답 헤더"
HTTP/1.1 200 OK
Cache-Control: no-store
Pragma: no-cache
Content-Type: application/json
```
```json label="응답 본문 — refresh_token은 없음"
{
"access_token": "<raw-keycloak-jwt>",
"token_type": "Bearer",
"expires_at": "<ISO-8601-instant>"
}
```
access token만 HTTP 응답 본문에 반환한다.
authorized client나 access token이 없으면 401이 된다.
## 브라우저에서 access token을 확인한 지점
브라우저 JavaScript는 이 응답을 지역 변수로 분해한다.
```javascript label="Web Storage에도 cookie에도 쓰지 않는다"
const {
access_token: accessToken,
expires_at: expiresAt
} = await tokenResponse.json();
```
그리고 바로 다음 요청의 헤더가 된다.
```http label="mediator를 지나지 않는 경로"
GET http://localhost:8081/api/me
Accept: application/json
Authorization: Bearer <raw-keycloak-jwt>
Origin: http://localhost:8082
```
실행 중 access token 원문은 다음 세 지점에서 확인된다.
```text
/token/access response body
→ JavaScript local variable
→ /api/me Authorization header
```
응답 처리와 JavaScript 변수, fetch 호출은 모두 같은 브라우저 실행 영역에서 처리된다.
memory-only는 영구 저장소에 쓰지 않는다는 뜻이다. 실행 중 script가 응답이나 지역 변수를 읽을 수 없다는 뜻이 아니다.
## /token/access는 일회성 전달이 아니다
이 endpoint가 한 번만 건네고 끝나는 교환인지 확인했다.
| one-time handoff 요건 | 있나 |
|---|---|
| handoff ID | x |
| nonce | x |
| 사용 표시(consume flag) | x |
| 건넨 뒤 삭제 | x |
| 재호출 거부 | x |
같은 인증된 session은 현재 access token을 몇 번이든 다시 받을 수 있다.
```text
repeatable GET
→ current authorized client lookup/refresh opportunity
→ current raw access token response
```
이 mediator가 허용하는 부분은 브라우저에 **access-only**다.
## 이 구조에서 감수한 것
- server state : mediator의 HttpSession과 authorized-client 저장소를 운영해야 한다
- browser 노출 : access token은 여전히 응답 본문과 헤더에 있다
이 구조를 고를 이유는 브라우저가 Resource Server를 직접 호출해야 한다는 요구가 있을 때다. 브라우저에서 access token까지 없애려는 목적이라면 이 구조는 맞지 않는다. server state 자체를 둘 수 없다면 SPA 구성이 더 단순하다.
## 확인한 것과 확인하지 않은 것
아래는 **커밋된 자동 테스트가 확인하도록 정의한 부분**이다.
| 항목 | 확인했나? |
|---|---|
| server access·refresh boolean이 true | o |
| `browserReceivesRefreshToken`이 false | o |
| 응답이 세 개 | o |
| `Cache-Control`에 `no-store` | o |
| audience에 `keycloak-pattern-api` 포함 | o |
| Resource Server 직접 호출 200 | o |
| cookie HttpOnly · SameSite=Lax | o |
| Web Storage에 token 문자열 없음 | o |
| 두 번째 `/token/access` 거부 | x |
| 만료 뒤 실제 refresh | x |
| logout 때 두 상태 삭제 | x |
| 재시작·replica 이동 뒤 복구 | x |
| 허용 밖 origin의 CORS 거부 | x |
만료 뒤 refresh 같은 경우는 manager에는 authorization-code와 refresh-token provider가 함께 구성돼 있다. 갱신을 시도할 수 있도록 만들 수도 있지만, 실제로 만료를 기다려 갱신이 성공하고 rotate된 token이 저장되는지는 확인하지 않았다.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,265 @@
---
id: 488ce49b-afa4-42a5-a2ce-de2e0653cd82
kind: CASE
slug: split-custody-access-token
title: Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 20
verifiedOn: 2026-08-24
studio: "https://hyeonworks.com/studio/documents/488ce49b-afa4-42a5-a2ce-de2e0653cd82/edit"
public: "https://hyeonworks.com/cases/split-custody-access-token"
---
# Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조
confidential client인 mediator가 authorization code를 token으로 교환하고 refresh token을 server-side authorized client에 보관한다. 브라우저는 Resource Server를 직접 호출하므로 mediator의 `/token/access`에서 access token을 받아 `Authorization` 헤더에 사용한다.
## 관계
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
SPA에서는 브라우저가 code 교환과 token 보관을 직접 수행한다. 이 Case에서는 code 교환과 refresh token 보관을 mediator가 수행하도록 구성했다.
- **Public Client와 Confidential Client 구분 기준**
confidential client를 쓰면서도 access token이 브라우저 응답에 실린다. 종류와 token 노출이 별개라는 근거다.
- **OAuth Token과 Application Session을 구분하는 기준**
access token 원문이 응답 본문과 지역 변수와 헤더를 지난다. 상태별 이름을 나눠야 하는 이유다.
- **OAuth/OIDC 인증 패턴 선택 기준**
mediator가 refresh token을 관리하면서도 브라우저가 Resource Server를 직접 호출하는 구성을 비교할 때 사용하는 Case다.
- **Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가**
refresh token rotation과 재사용 0회를 쓰는 구성이다. replica 경쟁 질문의 전제다.
## 문제
Mediator에서는 Spring mediator가 confidential client가 되어 code를 교환하고
access token과 refresh token을 server-side authorized-client service에 저장한다.
브라우저에는 HttpOnly AP2_SESSION만 관리하게 된다.
여기까지만 보면 BFF 구조와 같아 보이지만,
Mediator의 브라우저는 여전히 Resource Server를 직접 호출하고 있다.
그러면 access token이 필요하고, mediator가 그것을 응답으로 반환하게 된다.
`/token/access` 응답을 확인해 보니 mediator가 refresh token을 보관하더라도 access token은 브라우저에 전달되고 있었다. 브라우저가 Resource Server를 직접 호출하는 구조에서는 access token 전달이 필요했다.
## 결론
client secret과 refresh token은 mediator가 관리한다. access token은 `/token/access` 응답 본문, JavaScript 변수, `Authorization` 헤더에서 확인된다.
access token을 확인할 수 있는 지점
/token/access 응답 본문 : o
JavaScript 지역 변수 : o
/api/me Authorization 헤더 : o
server state : mediator의 session과 authorized-client 저장소를 운영해야 한다.
browser 노출 : access token은 브라우저 실행 영역 안에 그대로 있다.
## 검증 환경
Keycloak 26.7.0
realms
client-confidential : o
implicit flow, direct grant : x
client_authentication : client_secret_basic
grant_type : authorization_code
scopes : openid profile email
callback : http://localhost:8082/login/oauth2/
code/keycloak
principal claim : preferred_username
OAuth2AuthorizedClientService : Spring Boot의 in-memory
Spring Session, Redis, JDBC token store 의존성 : x
Resource Server CORS allowlist
origin : http://localhost:8082
method : GET, OPTIONS
header : Authorization, Content-Type
HTTPS : x
HTTP : o
## 재현 조건
1. Mediator UI에서 로그인한 뒤 /token/boundary를 호출.
accessTokenStored : true
refreshTokenStored : true
browserReceivesRefreshToken : false
2. /token/access 응답의 key가 정확히 세 개인지 확인.
access_token, token_type, expires_at
3. 같은 응답의 Cache-Control에 no-store가 있는지 확인.
4. 반환된 access JWT를 decode해 audience에 keycloak-pattern-api가 있는지 확인.
5. 브라우저가 그 token으로 Resource Server를 직접 호출해 200을 받는지 확인.
6. cookie가 AP2_SESSION이며 HttpOnly와 SameSite=Lax인지 확인.
7. Local Storage와 Session Storage에 access token 원문이나 refresh_token 문자열이 없는지 확인.
## 본문
<!-- body:start -->
## 토큰 관리 경계가 나뉘는 지점
:::evidence key="ap2-split-custody-779cb791" alt="Spring mediator의 authorized client 안에 access token과 refresh token이 함께 있고, 그중 access token만 브라우저 실행 영역으로 돌아오는 그림. 브라우저에서 Resource Server로 가는 Authorization Bearer 화살표는 mediator를 지나지 않는다. 브라우저 실행 영역 전체가 실행 중 XSS가 닿는 범위로 표시돼 있다." caption="" zoom="true"
:::
mediator는 access token과 refresh token을 모두 보관한다. 다만 브라우저가 Resource Server를 직접 호출해야 해서, access token은 `/token/access`를 통해 다시 브라우저로 전달된다.
## Mediator가 담당하는 OAuth 처리
SPA 구조에서는 브라우저가 authorization code를 직접 token으로 교환한다. Mediator 구조에서는 Spring backend가 confidential client로 등록되어 code 교환과 authorized client 저장을 처리한다.
SPA와 Mediator에서 각 동작을 수행하는 주체는 다음과 같다.
| 무엇 | 브라우저에 있나 | 서버에 있나 |
|---|---|---|
| client secret | x | o |
| refresh token | x | o |
| access token | o | o |
| 로그인 상태 | AP2_SESSION | HttpSession |
세 번째 줄이 이 Case의 관측이다. access token은 양쪽에 있다.
## AP2_SESSION이 생성되는 시점
`AP2_SESSION`이 token 교환을 마친 뒤에 발급된다고 읽기 쉽지만 그렇지 않다.
Spring Security는 로그인을 시작할 때 authorization request와 `state`를 HttpSession에 저장하고, 그 transaction을 찾기 위한 cookie를 먼저 발급한다. Browser가 KeyCloak으로 이동했다가 다시 Spring으로 다시 돌아왔을 때, Spring이 이 사용자가 아까 시작했던 로그인 요청이 무었이었는지 찾을 수 있어야 하기 때문에 로그인 시작 시점에 session 쿠키를 먼저 만들게 된다.
```text label="callback 하나가 두 개의 상태로 나뉜다"
AP2_SESSION
→ servlet HttpSession의 login SecurityContext
→ Authentication(principal name = preferred_username)
("keycloak", principal name)
→ OAuth2AuthorizedClientService
→ access token + refresh token
```
cookie가 token을 직렬화해 담고 있는 것이 아니다. cookie는 HttpSession을 식별하는 세션 ID이고, 그 HttpSession 안에 로그인 SecurityContext가 저장되어 있다. token은 이 cookie session에 담겨져 있는 principal을 가지고 별도 store에서 관리되고 있는 token을 찾는 것이다.
:::warning
`OAuth2AuthorizedClientService` 은 Spring Boot 자동구성이 고르는 in-memory 구현이고 Spring Session·Redis·JDBC token store 의존성도 없다. 로그인 상태와 token 상태가 **둘 다** process-local memory에 있다.
:::
## /token/access가 반환하는 세 가지 field
브라우저가 API를 호출하려면 access token이 필요하다. mediator는 이 endpoint로 반환하게 된다.
```http label="브라우저 입력 — cookie 한 개"
GET http://localhost:8082/token/access
Accept: application/json
Cookie: AP2_SESSION=<opaque-session-id>
```
controller는 `OAuth2AuthorizeRequest.withClientRegistrationId("keycloak")`을 만들고 현재 `Authentication`을 principal로 넣어 `OAuth2AuthorizedClientManager.authorize()`를 부른다. 돌아온 authorized client에서 access token만 꺼내 세 field로 만든다.
```http label="응답 헤더"
HTTP/1.1 200 OK
Cache-Control: no-store
Pragma: no-cache
Content-Type: application/json
```
```json label="응답 본문 — refresh_token은 없음"
{
"access_token": "<raw-keycloak-jwt>",
"token_type": "Bearer",
"expires_at": "<ISO-8601-instant>"
}
```
access token만 HTTP 응답 본문에 반환한다.
authorized client나 access token이 없으면 401이 된다.
## 브라우저에서 access token을 확인한 지점
브라우저 JavaScript는 이 응답을 지역 변수로 분해한다.
```javascript label="Web Storage에도 cookie에도 쓰지 않는다"
const {
access_token: accessToken,
expires_at: expiresAt
} = await tokenResponse.json();
```
그리고 바로 다음 요청의 헤더가 된다.
```http label="mediator를 지나지 않는 경로"
GET http://localhost:8081/api/me
Accept: application/json
Authorization: Bearer <raw-keycloak-jwt>
Origin: http://localhost:8082
```
실행 중 access token 원문은 다음 세 지점에서 확인된다.
```text
/token/access response body
→ JavaScript local variable
→ /api/me Authorization header
```
응답 처리와 JavaScript 변수, fetch 호출은 모두 같은 브라우저 실행 영역에서 처리된다.
memory-only는 영구 저장소에 쓰지 않는다는 뜻이다. 실행 중 script가 응답이나 지역 변수를 읽을 수 없다는 뜻이 아니다.
## /token/access는 일회성 전달이 아니다
이 endpoint가 한 번만 건네고 끝나는 교환인지 확인했다.
| one-time handoff 요건 | 있나 |
|---|---|
| handoff ID | x |
| nonce | x |
| 사용 표시(consume flag) | x |
| 건넨 뒤 삭제 | x |
| 재호출 거부 | x |
같은 인증된 session은 현재 access token을 몇 번이든 다시 받을 수 있다.
```text
repeatable GET
→ current authorized client lookup/refresh opportunity
→ current raw access token response
```
이 mediator가 허용하는 부분은 브라우저에 **access-only**다.
## 이 구조에서 감수한 것
- server state : mediator의 HttpSession과 authorized-client 저장소를 운영해야 한다
- browser 노출 : access token은 여전히 응답 본문과 헤더에 있다
이 구조를 고를 이유는 브라우저가 Resource Server를 직접 호출해야 한다는 요구가 있을 때다. 브라우저에서 access token까지 없애려는 목적이라면 이 구조는 맞지 않는다. server state 자체를 둘 수 없다면 SPA 구성이 더 단순하다.
## 확인한 것과 확인하지 않은 것
아래는 **커밋된 자동 테스트가 확인하도록 정의한 부분**이다.
| 항목 | 확인했나? |
|---|---|
| server access·refresh boolean이 true | o |
| `browserReceivesRefreshToken`이 false | o |
| 응답이 세 개 | o |
| `Cache-Control`에 `no-store` | o |
| audience에 `keycloak-pattern-api` 포함 | o |
| Resource Server 직접 호출 200 | o |
| cookie HttpOnly · SameSite=Lax | o |
| Web Storage에 token 문자열 없음 | o |
| 두 번째 `/token/access` 거부 | x |
| 만료 뒤 실제 refresh | x |
| logout 때 두 상태 삭제 | x |
| 재시작·replica 이동 뒤 복구 | x |
| 허용 밖 origin의 CORS 거부 | x |
만료 뒤 refresh 같은 경우는 manager에는 authorization-code와 refresh-token provider가 함께 구성돼 있다. 갱신을 시도할 수 있도록 만들 수도 있지만, 실제로 만료를 기다려 갱신이 성공하고 rotate된 token이 저장되는지는 확인하지 않았다.
<!-- body:end -->
@@ -0,0 +1,215 @@
## BFF가 Resource Server를 호출하는 흐름
:::evidence key="ap3-bff-custody-82fa18bd" alt="브라우저 안에 HttpOnly AP3_SESSION과 JavaScript가 읽을 수 있는 XSRF-TOKEN이 있고 OAuth token 칸은 점선으로 비어 있는 그림. BFF의 authorized client가 access token과 refresh token을 들고 있으며 Resource Server로 가는 Authorization Bearer 화살표는 BFF 아래에서 시작한다. 브라우저 실행 영역 전체가 실행 중 XSS가 닿는 범위로 표시돼 있다." caption="" zoom="true"
:::
브라우저는 BFF endpoint를 session cookie로 호출한다. BFF는 authorized client에서 access token을 가져와 Resource Server 요청의 `Authorization` 헤더를 만든다.
## 브라우저가 전송하는 session과 CSRF token
| 무엇 | 브라우저에 있나 | JavaScript가 읽나 |
|---|---|---|
| AP3_SESSION | o | x |
| XSRF-TOKEN | o | o |
| access token | x | x |
| refresh token | x | x |
JavaScript는 `XSRF-TOKEN` cookie 값을 읽어 상태 변경 요청의 `X-XSRF-TOKEN` 헤더에 넣는다. 이 용도 때문에 `XSRF-TOKEN`에는 `HttpOnly`를 사용하지 않았다.
same-origin에서 악성 script가 실행되면 사용자의 session으로 BFF endpoint를 호출할 수 있고 `XSRF-TOKEN`도 읽을 수 있다. BFF 구조의 차이는 OAuth token 원문을 브라우저 JavaScript에 전달하지 않는다는 점이다.
## Session으로 Authorized Client를 조회하는 과정
브라우저 요청에는 `Authorization` 헤더도 없고 코드에도 access token 지역 변수도 없다.
```http label="브라우저 입력 — cookie 하나"
GET http://localhost:8083/bff/api/me
Accept: application/json
Cookie: AP3_SESSION=<opaque-session-id>
```
cookie 자체는 token을 들고 있지 않다. cookie가 session을 식별하고, 그 session에서 얻은 인증 주체로 authorized client를 찾는다.
```text label="cookie에서 Bearer까지"
AP3_SESSION
→ HttpSession
→ SecurityContext
→ Authentication.getName()
→ ("keycloak", principal name)
→ OAuth2AuthorizedClientService
→ access token + refresh token
```
`BffController.currentUser(Authentication)`는 `OAuth2AuthorizeRequest`를 만들어 `OAuth2AuthorizedClientManager.authorize()`를 호출한다. manager bean은 `AuthorizedClientServiceOAuth2AuthorizedClientManager`이고 authorization-code와 refresh-token provider를 함께 사용하므로 만료된 access token의 갱신도 이 경로에서 처리한다.
없으면 401이 된다.
있으면 BFF의 `RestClient`가 downstream 입력을 **새로** 조립한다.
```http label="cookie로 조회된 토큰을 넣어서 조립"
GET http://app:8081/api/me
Authorization: Bearer <server-held-access-token>
```
`AP3_SESSION`은 downstream으로 전달되지 않는다.
BFF가 session을 해당 session에 맞는 token을 조회 후, Resource Server가 아는 Bearer credential로 바꾼다.
두 credential은 같은 요청 안에 있지만 서로 다른 경계로 나뉘게 된다.
:::warning
Compose는 학습 편의를 위해 Resource Server의 8081을 host에도 publish한다. 테스트는 AP3 UI가 8081을 직접 부르지 않는다는 것만 확인.
:::
## browserTokenCount는 무엇을 증명하나
진단용 endpoint가 server custody를 boolean으로 보여 준다.
```json label="/bff/token-boundary 응답"
{
"pattern": "AP3-backend-for-frontend",
"principal": "regular-user",
"accessTokenStoredOnServer": true,
"refreshTokenStoredOnServer": true,
"browserTokenCount": 0,
"csrfProtectionEnabled": true
}
```
`browserTokenCount: 0`은 브라우저를 실제로 검사해 센 값이 아니라 controller가 넣는 literal이다. 이 field 하나로는 token 비노출을 말할 수 없다.
밖에서 따로 봤다. 로그인 이후 개발자 도구에서 요청 목록과 저장소를 확인했더니 Keycloak token endpoint 호출이 없었고 Resource Server의 8081 직접 호출도 없었다. localStorage와 sessionStorage에도 accessToken·refreshToken 문자열이 없었다.
```text label="같은 주장에 대한 두 종류의 근거"
self-report /bff/token-boundary → browserTokenCount: 0
external observation 브라우저 network → token endpoint 없음
Web Storage → token 문자열 없음
```
자기 자신을 보고하는 값과 밖에서 관측한 값을 같은 증거로 취급하지 않는다.
이 endpoint는 manager의 `authorize()`를 호출하지 않고 `OAuth2AuthorizedClientService`를 직접 조회하므로 여기서는 refresh를 수행하지 않는다.
## cookie가 credential이면 CSRF가 필요하다
브라우저는 session cookie를 요청마다 자동으로 붙인다. `GET`만 보면 이것이 문제로 보이지 않는다. 값을 바꾸는 요청에서 보이게 되는데, 먼저 브라우저가 CSRF material을 받는다.
```http label="응답 헤더 — cookie에는 raw 값이 들어간다"
HTTP/1.1 200 OK
Cache-Control: no-store
Pragma: no-cache
Set-Cookie: XSRF-TOKEN=<raw-csrf-token>; Path=/
```
```json label="응답 본문 — 여기 token은 가려진 값이다"
{
"headerName": "X-XSRF-TOKEN",
"parameterName": "_csrf",
"token": "<xor-masked-csrf-token>"
}
```
같은 endpoint가 두 값을 반환하게 되는데, 이 **둘은 같은 문자열이 아니다.**
:::evidence key="ap3-csrf-split-501dd1f7" alt="BFF의 CSRF endpoint 하나에서 두 갈래가 갈리는 그림. 위쪽은 raw token이 담긴 XSRF-TOKEN cookie, 아래쪽은 가려진 token과 headerName이 담긴 JSON body다. 두 갈래가 POST 조립 단계로 모이지만 실제 X-XSRF-TOKEN 값은 cookie의 raw token이고 JSON에서는 headerName만 쓴다. 마지막으로 CSRF filter가 대조한다." caption="" zoom="true"
:::
`CookieCsrfTokenRepository.withHttpOnlyFalse()`가 cookie에 raw 값을 넣는다. `XorCsrfTokenRequestAttributeHandler`가 request attribute용 token을 XOR와 Base64로 가리기 때문에 응답 본문에는 가려진 값이 보인다.
SPA는 본문의 `token`을 쓰지 않는다. 본문에서는 `headerName`만 읽고, `document.cookie`에서 raw `XSRF-TOKEN`을 찾아 헤더 값으로 넣는다.
```text label="CSRF token 표현 비교"
body.token masked token
cookie XSRF-TOKEN raw token
X-XSRF-TOKEN raw token
```
```http label="다음 요청 헤더에 X-XSRF-TOKEN가 들어간다"
POST /bff/theme HTTP/1.1
Host: localhost:8083
Content-Type: application/json
Cookie: AP3_SESSION=<opaque-session-id>; XSRF-TOKEN=<raw-csrf-token>
X-XSRF-TOKEN: <raw-csrf-token>
```
```json label="요청 본문"
{
"theme":"dark"
}
```
`SpaCsrfTokenRequestHandler`가 노출하는 형태와 제출받는 형태를 나눠 처리한다. 요청 헤더에 raw 값이 실려 오면 그 값을 cookie와 대조한다.
:::note
응답 본문의 token을 가리는 것은 BREACH 완화다. HTTP 응답 압축 크기의 차이로 응답 안의 비밀값을 좁혀 가는 공격이라 노출되는 형태를 매번 다르게 만든다.
:::
## SameSite와 CSRF token이 막는 입력
네 가지 입력으로 나눠 보면 둘이 갈린다.
| 입력 | 막는 것 | 응답 |
|---|---|---|
| same-origin, 헤더 없음 | CSRF token | 403 |
| same-site 다른 port, 헤더 없음 | CSRF token | 403 |
| cross-site POST | SameSite | cookie 누락 |
| same-origin, 값 일치 | 통과 | 200 |
앞의 두 요청에는 cookie가 포함되므로 CSRF token 검증이 필요하다. 셋째 요청은 cookie가 전송되지 않는다. **port가 달라도 site 계산상 같은 경우가 있어** SameSite만으로 둘째 요청을 차단할 수는 없다.
셋째 줄의 관측 지점은 최종 status가 아니라 **cookie가 요청에서 빠졌다는 부분**이다.
## BFF에서 관리해야 하는 항목
현재 BFF 구현에서 직접 관리하는 항목은 다음과 같다.
| 관리 항목 | 현재 구현 |
|---|---|
| 상태 변경 요청의 CSRF 검증 | o |
| 재시작 뒤 로그인 유지 | x |
| replica가 함께 쓰는 session | x |
| 저장 token 암호화 | x |
| logout 때 session과 authorized client 삭제 | x |
| downstream 오류를 화면 오류로 변환 | x |
| timeout · retry · circuit breaker | x |
| 경로별 인가 | x |
첫 줄만 구현돼 있다. 나머지는 이 BFF가 단일 인스턴스 memory와 한 번의 BFF 호출로만 보여 준다.
저장소도 생각한 모양이 아니다. 현재 store는 session ID마다 독립된 token 저장소가 아니라 registration과 principal name으로 authorized client를 찾는 **애플리케이션 수준 store**다. 같은 principal이 여러 브라우저 session에서 로그인하면 같은 항목을 공유하거나 덮어쓸 수 있다.
## 확인한 것과 확인하지 않은 것
아래는 **커밋된 자동 테스트가 확인하도록 정의한 부분**이다.
| 항목 | 확인했나 |
|---|---|
| `bff-confidential` + S256 challenge | o |
| 브라우저 요청에 token endpoint 없음 | o |
| 브라우저 요청에 8081 직접 호출 없음 | o |
| `AP3_SESSION` HttpOnly · SameSite=Lax | o |
| Web Storage 비어 있음 | o |
| server access·refresh boolean이 true | o |
| `/bff/api/me` 200 · username · audience | o |
| CSRF 헤더 없는 POST 403 | o |
| raw 값을 헤더에 넣은 POST 200 | o |
| cross-site POST에서 cookie 누락 | o |
| preference의 사용자별 격리 | x |
| preference 영속성 | x |
| 공유 session store | x |
| 저장 token 암호화 | x |
| logout | x |
| downstream 401의 전달 모양 | x |
| timeout · 경로별 인가 | x |
## 이 구조에서 관측한 것
브라우저 network에서는 Keycloak token endpoint를 직접 호출하지 않았고 `/bff/api/me`에도 `Authorization: Bearer`가 없었다. 해당 요청은 `AP3_SESSION`으로 인증됐으며, 상태 변경 요청에는 CSRF token 검증을 적용했다. 현재 로그인 session과 authorized client는 BFF process memory에 저장된다.
브라우저에 OAuth token을 전달하지 않고 backend가 여러 API 호출을 조합해야 하는 요구에는 BFF가 맞다. 브라우저가 Resource Server를 직접 호출해야 한다면 SPA나 Mediator 구조를 검토한다.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,320 @@
---
id: d85bd6af-7599-4ef7-9407-6609927d5b5c
kind: CASE
slug: bff-session-csrf-responsibility
title: BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 28
verifiedOn: 2026-08-25
studio: "https://hyeonworks.com/studio/documents/d85bd6af-7599-4ef7-9407-6609927d5b5c/edit"
public: "https://hyeonworks.com/cases/bff-session-csrf-responsibility"
---
# BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정
로그인 후 브라우저는 `AP3_SESSION`으로 BFF를 호출하고, BFF가 server-side authorized client에서 access token을 조회해 Resource Server를 호출한다. 상태 변경 요청에는 `XSRF-TOKEN``X-XSRF-TOKEN` 검증을 추가했다.
## 관계
- **BFF 인증 구조 설계 기준**
이 기준이 요구하는 항목 중 무엇이 구현됐고 무엇이 구현되지 않았는지
- **OAuth Token과 Application Session을 구분하는 기준**
session cookie와 CSRF token, server-side token을 각각 다뤄야 하는 이유
- **OAuth/OIDC 인증 패턴 선택 기준**
BFF 구조에서 필요한 CSRF 검증과 server-side 상태 저장 기준을 함께 다룬다
- **BFF가 OAuth Token을 관리하는 조건**
이 결정의 구조를 실제로 실행해 본 문서
- **서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가**
두 상태가 모두 process-local memory에 있다는 점이 질문의 시작이다
- **BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가**
session과 authorized client의 2가지 흐름
## 문제
BFF에서는 confidential-client인 BFF서버가 code를 교환하고
access token과 refresh token은 server-side authorized client에 관리하게 된다.
브라우저에는 HttpOnly AP3_SESSION만 전달된다.
그런데 브라우저는 여전히 요청마다 cookie를 보낸다.
cookie가 credential이면 상태를 바꾸는 요청은 사용자의 의도인지 따로 확인해야 한다.
BFF는 이제 재시작과 replica 이동에 따른 저장소가 필요하다.
BFF가 OAuth token을 관리하도록 구성한 뒤 브라우저 요청 방식과 CSRF 처리, server-side 저장 상태를 확인했다.
## 결론
상태 변경 요청에서 브라우저가 보내는 cookie는 `AP3_SESSION``XSRF-TOKEN`이다.
AP3_SESSION : HttpOnly, JavaScript 읽기 x
XSRF-TOKEN : JavaScript 읽기 o
브라우저는 session cookie를 요청에 자동으로 포함한다. 상태 변경 요청에서는 CSRF token을 별도로 검증하며, JavaScript가 헤더 값을 만들 수 있도록 `XSRF-TOKEN` cookie에는 HttpOnly를 사용하지 않았다.
BFF를 사용해도 same-origin XSS는 별도로 막아야 한다. 악성 script가 실행되면 현재 session으로 BFF를 호출할 수 있고 JavaScript에서 읽을 수 있는 CSRF cookie에도 접근할 수 있다. 다만 OAuth token 원문을 브라우저 JavaScript에 전달하지는 않는다.
현재 구현에서는 BFF가 CSRF 검증까지 처리한다.
재시작 뒤 로그인 유지, replica 공유 session, 저장 token 암호화, logout, downstream 오류 변환, timeout, 경로별 인가 : x
## 검증 환경
Keycloak 26.7.0
realms
confidential, client_secret_basic
PKCE S256 : o
provider : authorization-code, refresh-token
store : memory o
CSRF : o
HTTP : o
## 재현 조건
1. UI에서 로그인하고 authorization request를 확인.
client_id : bff-confidential
code_challenge_method : S256
2. 브라우저 요청 목록에 Keycloak token endpoint와 8081 직접 호출이 없는지 확인.
3. cookie가 AP3_SESSION이며 HttpOnly와 SameSite=Lax인지, Web Storage가 비었는지 확인.
4. /bff/token-boundary를 호출.
accessTokenStoredOnServer : true
refreshTokenStoredOnServer : true
browserTokenCount : 0
csrfProtectionEnabled : true
5. /bff/api/me가 200이고 downstream 응답에 username과 audience가 있는지 확인.
6. GET /bff/csrf로 XSRF-TOKEN cookie와 token metadata를 받는거 확인.
응답 본문의 token과 cookie 값이 같은 문자열이 아님을 확인.
7. session cookie는 있고 CSRF 헤더가 없는 POST /bff/api/preferences가 403인지 확인.
8. cookie의 raw 값을 X-XSRF-TOKEN에 넣은 같은 POST가 200이고 theme이 dark인지 확인.
9. 127.0.0.1에서 localhost로 보내는 cross-site POST에서 AP3_SESSION이 요청에 실리지 않는지 확인.
## 본문
<!-- body:start -->
## BFF가 Resource Server를 호출하는 흐름
:::evidence key="ap3-bff-custody-82fa18bd" alt="브라우저 안에 HttpOnly AP3_SESSION과 JavaScript가 읽을 수 있는 XSRF-TOKEN이 있고 OAuth token 칸은 점선으로 비어 있는 그림. BFF의 authorized client가 access token과 refresh token을 들고 있으며 Resource Server로 가는 Authorization Bearer 화살표는 BFF 아래에서 시작한다. 브라우저 실행 영역 전체가 실행 중 XSS가 닿는 범위로 표시돼 있다." caption="" zoom="true"
:::
브라우저는 BFF endpoint를 session cookie로 호출한다. BFF는 authorized client에서 access token을 가져와 Resource Server 요청의 `Authorization` 헤더를 만든다.
## 브라우저가 전송하는 session과 CSRF token
| 무엇 | 브라우저에 있나 | JavaScript가 읽나 |
|---|---|---|
| AP3_SESSION | o | x |
| XSRF-TOKEN | o | o |
| access token | x | x |
| refresh token | x | x |
JavaScript는 `XSRF-TOKEN` cookie 값을 읽어 상태 변경 요청의 `X-XSRF-TOKEN` 헤더에 넣는다. 이 용도 때문에 `XSRF-TOKEN`에는 `HttpOnly`를 사용하지 않았다.
same-origin에서 악성 script가 실행되면 사용자의 session으로 BFF endpoint를 호출할 수 있고 `XSRF-TOKEN`도 읽을 수 있다. BFF 구조의 차이는 OAuth token 원문을 브라우저 JavaScript에 전달하지 않는다는 점이다.
## Session으로 Authorized Client를 조회하는 과정
브라우저 요청에는 `Authorization` 헤더도 없고 코드에도 access token 지역 변수도 없다.
```http label="브라우저 입력 — cookie 하나"
GET http://localhost:8083/bff/api/me
Accept: application/json
Cookie: AP3_SESSION=<opaque-session-id>
```
cookie 자체는 token을 들고 있지 않다. cookie가 session을 식별하고, 그 session에서 얻은 인증 주체로 authorized client를 찾는다.
```text label="cookie에서 Bearer까지"
AP3_SESSION
→ HttpSession
→ SecurityContext
→ Authentication.getName()
→ ("keycloak", principal name)
→ OAuth2AuthorizedClientService
→ access token + refresh token
```
`BffController.currentUser(Authentication)`는 `OAuth2AuthorizeRequest`를 만들어 `OAuth2AuthorizedClientManager.authorize()`를 호출한다. manager bean은 `AuthorizedClientServiceOAuth2AuthorizedClientManager`이고 authorization-code와 refresh-token provider를 함께 사용하므로 만료된 access token의 갱신도 이 경로에서 처리한다.
없으면 401이 된다.
있으면 BFF의 `RestClient`가 downstream 입력을 **새로** 조립한다.
```http label="cookie로 조회된 토큰을 넣어서 조립"
GET http://app:8081/api/me
Authorization: Bearer <server-held-access-token>
```
`AP3_SESSION`은 downstream으로 전달되지 않는다.
BFF가 session을 해당 session에 맞는 token을 조회 후, Resource Server가 아는 Bearer credential로 바꾼다.
두 credential은 같은 요청 안에 있지만 서로 다른 경계로 나뉘게 된다.
:::warning
Compose는 학습 편의를 위해 Resource Server의 8081을 host에도 publish한다. 테스트는 AP3 UI가 8081을 직접 부르지 않는다는 것만 확인.
:::
## browserTokenCount는 무엇을 증명하나
진단용 endpoint가 server custody를 boolean으로 보여 준다.
```json label="/bff/token-boundary 응답"
{
"pattern": "AP3-backend-for-frontend",
"principal": "regular-user",
"accessTokenStoredOnServer": true,
"refreshTokenStoredOnServer": true,
"browserTokenCount": 0,
"csrfProtectionEnabled": true
}
```
`browserTokenCount: 0`은 브라우저를 실제로 검사해 센 값이 아니라 controller가 넣는 literal이다. 이 field 하나로는 token 비노출을 말할 수 없다.
밖에서 따로 봤다. 로그인 이후 개발자 도구에서 요청 목록과 저장소를 확인했더니 Keycloak token endpoint 호출이 없었고 Resource Server의 8081 직접 호출도 없었다. localStorage와 sessionStorage에도 accessToken·refreshToken 문자열이 없었다.
```text label="같은 주장에 대한 두 종류의 근거"
self-report /bff/token-boundary → browserTokenCount: 0
external observation 브라우저 network → token endpoint 없음
Web Storage → token 문자열 없음
```
자기 자신을 보고하는 값과 밖에서 관측한 값을 같은 증거로 취급하지 않는다.
이 endpoint는 manager의 `authorize()`를 호출하지 않고 `OAuth2AuthorizedClientService`를 직접 조회하므로 여기서는 refresh를 수행하지 않는다.
## cookie가 credential이면 CSRF가 필요하다
브라우저는 session cookie를 요청마다 자동으로 붙인다. `GET`만 보면 이것이 문제로 보이지 않는다. 값을 바꾸는 요청에서 보이게 되는데, 먼저 브라우저가 CSRF material을 받는다.
```http label="응답 헤더 — cookie에는 raw 값이 들어간다"
HTTP/1.1 200 OK
Cache-Control: no-store
Pragma: no-cache
Set-Cookie: XSRF-TOKEN=<raw-csrf-token>; Path=/
```
```json label="응답 본문 — 여기 token은 가려진 값이다"
{
"headerName": "X-XSRF-TOKEN",
"parameterName": "_csrf",
"token": "<xor-masked-csrf-token>"
}
```
같은 endpoint가 두 값을 반환하게 되는데, 이 **둘은 같은 문자열이 아니다.**
:::evidence key="ap3-csrf-split-501dd1f7" alt="BFF의 CSRF endpoint 하나에서 두 갈래가 갈리는 그림. 위쪽은 raw token이 담긴 XSRF-TOKEN cookie, 아래쪽은 가려진 token과 headerName이 담긴 JSON body다. 두 갈래가 POST 조립 단계로 모이지만 실제 X-XSRF-TOKEN 값은 cookie의 raw token이고 JSON에서는 headerName만 쓴다. 마지막으로 CSRF filter가 대조한다." caption="" zoom="true"
:::
`CookieCsrfTokenRepository.withHttpOnlyFalse()`가 cookie에 raw 값을 넣는다. `XorCsrfTokenRequestAttributeHandler`가 request attribute용 token을 XOR와 Base64로 가리기 때문에 응답 본문에는 가려진 값이 보인다.
SPA는 본문의 `token`을 쓰지 않는다. 본문에서는 `headerName`만 읽고, `document.cookie`에서 raw `XSRF-TOKEN`을 찾아 헤더 값으로 넣는다.
```text label="CSRF token 표현 비교"
body.token masked token
cookie XSRF-TOKEN raw token
X-XSRF-TOKEN raw token
```
```http label="다음 요청 헤더에 X-XSRF-TOKEN가 들어간다"
POST /bff/theme HTTP/1.1
Host: localhost:8083
Content-Type: application/json
Cookie: AP3_SESSION=<opaque-session-id>; XSRF-TOKEN=<raw-csrf-token>
X-XSRF-TOKEN: <raw-csrf-token>
```
```json label="요청 본문"
{
"theme":"dark"
}
```
`SpaCsrfTokenRequestHandler`가 노출하는 형태와 제출받는 형태를 나눠 처리한다. 요청 헤더에 raw 값이 실려 오면 그 값을 cookie와 대조한다.
:::note
응답 본문의 token을 가리는 것은 BREACH 완화다. HTTP 응답 압축 크기의 차이로 응답 안의 비밀값을 좁혀 가는 공격이라 노출되는 형태를 매번 다르게 만든다.
:::
## SameSite와 CSRF token이 막는 입력
네 가지 입력으로 나눠 보면 둘이 갈린다.
| 입력 | 막는 것 | 응답 |
|---|---|---|
| same-origin, 헤더 없음 | CSRF token | 403 |
| same-site 다른 port, 헤더 없음 | CSRF token | 403 |
| cross-site POST | SameSite | cookie 누락 |
| same-origin, 값 일치 | 통과 | 200 |
앞의 두 요청에는 cookie가 포함되므로 CSRF token 검증이 필요하다. 셋째 요청은 cookie가 전송되지 않는다. **port가 달라도 site 계산상 같은 경우가 있어** SameSite만으로 둘째 요청을 차단할 수는 없다.
셋째 줄의 관측 지점은 최종 status가 아니라 **cookie가 요청에서 빠졌다는 부분**이다.
## BFF에서 관리해야 하는 항목
현재 BFF 구현에서 직접 관리하는 항목은 다음과 같다.
| 관리 항목 | 현재 구현 |
|---|---|
| 상태 변경 요청의 CSRF 검증 | o |
| 재시작 뒤 로그인 유지 | x |
| replica가 함께 쓰는 session | x |
| 저장 token 암호화 | x |
| logout 때 session과 authorized client 삭제 | x |
| downstream 오류를 화면 오류로 변환 | x |
| timeout · retry · circuit breaker | x |
| 경로별 인가 | x |
첫 줄만 구현돼 있다. 나머지는 이 BFF가 단일 인스턴스 memory와 한 번의 BFF 호출로만 보여 준다.
저장소도 생각한 모양이 아니다. 현재 store는 session ID마다 독립된 token 저장소가 아니라 registration과 principal name으로 authorized client를 찾는 **애플리케이션 수준 store**다. 같은 principal이 여러 브라우저 session에서 로그인하면 같은 항목을 공유하거나 덮어쓸 수 있다.
## 확인한 것과 확인하지 않은 것
아래는 **커밋된 자동 테스트가 확인하도록 정의한 부분**이다.
| 항목 | 확인했나 |
|---|---|
| `bff-confidential` + S256 challenge | o |
| 브라우저 요청에 token endpoint 없음 | o |
| 브라우저 요청에 8081 직접 호출 없음 | o |
| `AP3_SESSION` HttpOnly · SameSite=Lax | o |
| Web Storage 비어 있음 | o |
| server access·refresh boolean이 true | o |
| `/bff/api/me` 200 · username · audience | o |
| CSRF 헤더 없는 POST 403 | o |
| raw 값을 헤더에 넣은 POST 200 | o |
| cross-site POST에서 cookie 누락 | o |
| preference의 사용자별 격리 | x |
| preference 영속성 | x |
| 공유 session store | x |
| 저장 token 암호화 | x |
| logout | x |
| downstream 401의 전달 모양 | x |
| timeout · 경로별 인가 | x |
## 이 구조에서 관측한 것
브라우저 network에서는 Keycloak token endpoint를 직접 호출하지 않았고 `/bff/api/me`에도 `Authorization: Bearer`가 없었다. 해당 요청은 `AP3_SESSION`으로 인증됐으며, 상태 변경 요청에는 CSRF token 검증을 적용했다. 현재 로그인 session과 authorized client는 BFF process memory에 저장된다.
브라우저에 OAuth token을 전달하지 않고 backend가 여러 API 호출을 조합해야 하는 요구에는 BFF가 맞다. 브라우저가 Resource Server를 직접 호출해야 한다면 SPA나 Mediator 구조를 검토한다.
<!-- body:end -->
@@ -0,0 +1,206 @@
## 같은 이름의 헤더
:::evidence key="ap4-edge-trust-1cff2399" alt="왼쪽 외부 영역의 브라우저에 AP4_SESSION과 점선으로 표시된 client 제공 header가 있다. 가운데 Nginx는 8088만 공개하고 header 덮어쓰기를 맡는다. 오른쪽 점선 영역은 host port가 닫혀 있고 oauth2-proxy와 Spring upstream이 들어 있다. Nginx가 oauth2-proxy에 auth_request를 보내 user와 email을 받고, nginx-owned header와 internal token으로 upstream 요청을 만든다." caption="" zoom="true"
:::
`X-Auth-Request-User`는 인증을 마친 edge가 만들 수도 있고 공격자가 직접 적어 보낼 수도 있다.
client가 같은 이름의 헤더를 보낼 수 있기 때문에 upstream만으로는 `X-Auth-Request-User`가 edge에서 생성됐는지 판단할 수 없다.
## 위조 요청의 모양
로그인을 마친 브라우저가 정상 요청에 세 헤더를 넣었다고 하자.
```http label="공격자가 보낸 요청"
GET http://localhost:8088/api/edge
Cookie: AP4_SESSION=<opaque-session>
X-Auth-Request-User: spoofed-admin
X-Auth-Request-Email: spoofed-admin@example.test
X-Internal-Auth-Token: attacker-controlled-token
```
이 테스트의 assertion은 status code가 아니다. 정상 session을 함께 보냈으니 요청 자체는 200이 될 수 있다. 확인할 값은 응답의 `user`가 `spoofed-admin`으로 바뀌지 않았는지다.
## 세 개의 독립된 경계
현재 OAuth2-Proxy 구성에서는 세 단계에서 위조 요청을 차단한다.
| 위치 | 차단 대상 |
|---|---|
| host port 닫힘 | 외부에서 upstream·proxy로 가는 직접 경로 |
| Nginx header 덮어쓰기 | client가 보낸 동명 헤더 |
| upstream internal token | edge를 거치지 않은 내부 요청 |
host port를 외부에 열면 edge를 거치지 않고 backend에 접근할 수 있다. Nginx가 동명 헤더를 덮어쓰지 않으면 client가 보낸 identity 값이 upstream에 전달될 수 있다. backend의 internal credential 검증은 edge를 거치지 않은 내부 요청을 구분하는 데 사용한다.
network isolation과 internal credential 검증은 서로 다른 요청 경로를 통제하므로 둘 다 적용한다.
## Nginx가 헤더를 만드는 경계
Nginx는 먼저 internal subrequest를 만든다.
`location = /oauth2/auth`는 `internal`이라 Nginx가 만든 subrequest만 들어갈 수 있다.
```nginx label="upstream을 부르기 전에 먼저 물어본다"
auth_request /oauth2/auth;
```
oauth2-proxy가 session을 유효하다고 판단하면 결과를 헤더로 돌려준다. Nginx는 그 값을 지역 변수로 복사한다.
```text label="auth_request_set — 값의 출처가 여기서 고정"
$auth_user ← oauth2-proxy X-Auth-Request-User
$auth_email ← oauth2-proxy X-Auth-Request-Email
$auth_cookie ← oauth2-proxy Set-Cookie
```
그 다음 원래 요청을 그대로 넘기지 않는다. 외부 `/api/edge`는 내부 `/edge/me`로 다시 매핑되고, 세 헤더는 **merge가 아니라 덮어쓰기**로 채워진다.
```http label="upstream이 실제로 받는 요청"
GET http://app:8081/edge/me
X-Auth-Request-User: <oauth2-proxy-authenticated-user>
X-Auth-Request-Email: <oauth2-proxy-authenticated-email>
X-Internal-Auth-Token: <nginx-environment-secret>
```
그럼 client가 무엇을 보냈든 upstream 입력은 oauth2-proxy가 확인한 값이 된다.
## upstream은 무엇을 확인하나
`EdgeIdentityController.currentUser(HttpServletRequest)`가 `/edge/me`를 받는다.
1. `X-Auth-Request-User`를 읽고 비어 있는지 확인한다.
2. `X-Internal-Auth-Token`을 읽어 설정값과 `MessageDigest.isEqual`로 비교한다.
두 조건이 모두 맞을 때만 allowlist한 field를 응답에 넣는다.
```json label="정상 응답 — 4가지 필드"
{
"pattern": "AP4-edge-forward-auth",
"user": "regular-user",
"email": "regular-user@example.test",
"identityHeader": "X-Auth-Request-User"
}
```
하나라도 다르면 401이 된다.
```json label="user 헤더가 없거나 internal token이 틀릴 때"
{
"error": "trusted edge authentication is required"
}
```
internal token은 `MessageDigest.isEqual`로 비교했다. 문자열을 앞에서부터 비교하다 중단하는 방식보다 입력에 따른 비교 시간 차이를 줄이기 위한 선택이다.
:::danger
현재 `SecurityConfig`는 `/edge/**`를 `permitAll`로 두고 `/edge/me` controller가 직접 internal token을 확인한다. 새 edge endpoint를 추가하면서 같은 메서드를 부르지 않으면 보호 되지 않는다.
:::
운영에서는 controller마다 같은 검사를 반복하지 않도록 filter, interceptor, security chain 등 공통 경로에서 검증하도록 구성해야 한다.
## 경로마다 달라지는 결과
같은 미인증 요청이라도 경로에 따라 다른 응답이 나온다.
| 외부 입력 | 인증 상태 | 결과 |
|---|---|---|
| `GET /` | 미인증 | `/oauth2/start` 302 |
| `GET /api/edge` | 미인증 | redirect 없는 401 |
| `GET /oauth2/auth` | 무관 | 404 |
| `GET /` + 위조 헤더 | 정상 session | 실제 user 200 |
| `/edge/me` + user 헤더만 | edge token 없음 | 401 |
| `/edge/me` + 틀린 token | token 불일치 | 401 |
아래 두 줄은 내부에서 들어온 요청이다. 첫 줄과 둘째 줄이 다른 이유는 화면을 여는 요청과 프로그램이 부르는 요청이 원하는 실패 구조가 다르기 때문이다. 사람은 로그인 화면으로 가야 하고, 프로그램은 `Location` 없는 401을 받아야 한다.
**redirect 없는 JSON 401은 정확히 `/api/edge` 경로에만 구성돼 있다.**
다른 경로는 로그인 redirect 규칙을 따른다.
셋째 줄도 중요하다. 외부에서 `/oauth2/auth`를 직접 부르면 404다. `internal` 지정이 없으면 이 endpoint가 밖에서 부를 수 있는 인증 우회 지점이 된다.
## 브라우저가 가지고 있는 것
OAuth2-Proxy 구조는 server-side session store를 두지 않는다.
```text label="AP4_SESSION cookie 설정"
name = AP4_SESSION
HttpOnly = true
SameSite = Lax
Secure = false in local HTTP fixture
expire = 1 hour in proxy configuration
```
`session-cookie-minimal=true`에서는 oauth2-proxy가 필요한 최소 session 정보만 cookie에 저장한다. 이 cookie는 HttpOnly로 설정되어 JavaScript에서 읽지 않고, 브라우저가 다음 요청에 자동으로 전송한다.
지금 값은 local HTTP fixture 기준이다. HTTPS로 올리면 `Secure = true`로 바꿔야 한다. replica를 늘린다면 같은 cookie를 검증할 secret을 어떻게 배포하고 교체할지도 정해야 한다.
## endpoint를 외부용과 내부용으로 나눈 이유
브라우저가 도달해야 하는 주소와 container가 도달해야 하는 주소가 다르다.
이 구성에서는 자동 discovery를 사용하지 않고 필요한 endpoint 주소를 각각 지정한다.
```text label="issuer는 브라우저가 접속하는 부분"
issuer expected value = http://localhost:8080/realms/keycloak-patterns
login URL = http://localhost:8080/.../auth
redeem/token URL = http://keycloak:8080/.../token
JWKS/userinfo URL = http://keycloak:8080/...
```
issuer는 실제로 요청을 보내기 위한 주소가 아니라 KeyCloak이 발급한 토큰의 iss claim이 우리가 기대한 값과 같은지 검증하기 위한 기준값이다. 반면 token url, userinfo url 같은 경우는 실제로 내부에서 oauth2-proxy가 요청을 보내기 위해 사용되는 내부 네트워크 주소다.
따라서 둘다 keycloak realm을 가리키지만 용도가 다르고 브라우저는 docker 내부 호스트명인 `keycloak:8080`에 접근할 수 없기에 로그인에는 `localhost:8080`을 사용하고 컨테이너는 자신의 `localhost:8080`이 keycloak이 아니므로 내부 통신에는 `keycloak:8080`을 사용한다.
## upstream이 JWT를 받지 않는다
앞의 3가지 구조에서는 Resource Server는 JWT의 서명과 issuer, audience를 직접 확인한다. OAuth2-Proxy 구조의 `/edge/me`는 **JWT를 입력으로 받지 않는다.**
| 무엇을 믿나 | AP1~AP3 | AP4 |
|---|---|---|
| 서명된 JWT | o | x |
| network topology | x | o |
| internal token | x | o |
| edge의 user·email | x | o |
upstream은 edge가 검증한 결과와 edge가 추가한 헤더를 신뢰한다. 따라서 backend 직접 접근과 client가 보낸 동명 identity header를 차단하는 설정이 이 구조의 전제다.
## 헤더를 늘릴 때 정해야 하는 것
현재 edge 응답은 user와 email만 전달한다. role, groups, tenant, 인증 방식, token 만료는 전달하지 않는다. 금지하는 것은 아니지만, 헤더를 늘릴 때마다 계약을 정해야 한다.
- claim 출처 : oauth2-proxy나 별도 auth service가 어느 값을 읽는가
- allowlist : Nginx가 어느 응답 헤더만 복사하는가
- 덮어쓰기 : client가 보낸 동명 헤더를 항상 지우거나 덮어쓰는가
- 직렬화 : 다중 값, 구분자, escaping, 최대 크기는 무엇인가
- upstream 검증 : 헤더 존재만 볼지 값과 service identity까지 볼지
- 갱신 : role이 바뀌면 proxy session과 downstream 인가가 언제 따라가는가
## 확인한 것과 확인하지 않은 것
아래는 **커밋된 자동 테스트가 확인하도록 정의한 부분** 이다.
| 항목 | 확인한 부분 |
|---|---|
| cookie 없는 root의 302 | o |
| cookie 없는 `/api/edge`의 401 | o |
| `edge-proxy` + S256 challenge | o |
| `AP4_SESSION` HttpOnly · SameSite=Lax | o |
| 브라우저 요청에 token endpoint 없음 | o |
| Web Storage 비어 있고 cookie 읽기 불가 | o |
| 위조 헤더를 보내도 실제 user로 200 | o |
| 외부 `/oauth2/auth` 404 | o |
| host의 4180 · 8081 접근 불가 | o |
| user 헤더 없음 · token 없음 · token 불일치 401 | o |
| role 전달 | x |
| 새 endpoint의 공통 강제 | x |
| 상태 변경 요청의 CSRF | x |
| session 갱신 | x |
| replica 간 secret 공유 | x |
| internal secret 교체 | x |
일곱째 줄이 핵심이다. 요청이 실패하는지 보는 것이 아니라, **Nginx가 client 입력을 덮어쓰고 정상 identity를 반환하는지**를 본다.
## 증명하지 않는 것
현재 설정은 `/api/edge`와 `/` 요청을 모두 `/edge/me`로 전달한다. `/orders/123` 같은 임의 경로를 보존하는 범용 reverse proxy는 검증하지 않았으며 path, method, body, streaming, websocket 동작도 이번 Case의 검증 범위에 포함하지 않았다.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,327 @@
---
id: a0e1cc05-92b3-4dac-bce1-513ab8cd862b
kind: CASE
slug: identity-header-trust
title: Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 37
verifiedOn: 2026-08-25
studio: "https://hyeonworks.com/studio/documents/a0e1cc05-92b3-4dac-bce1-513ab8cd862b/edit"
public: "https://hyeonworks.com/cases/identity-header-trust"
---
# Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유
`X-Auth-Request-User`는 edge가 인증 결과로 추가하는 헤더지만 client도 같은 이름의 헤더를 보낼 수 있다. upstream이 이 값을 사용자 식별에 사용하므로 Nginx에서 client 값을 덮어쓰고, backend 직접 접근을 차단하며, backend에서도 internal credential을 검증하도록 구성했다.
## 관계
- **Forward-Auth에서 Identity Header를 신뢰하기 위한 조건**
identity header를 신뢰하기 위한 조건을 Nginx, oauth2-proxy, backend 설정과 요청 결과로 확인했다.
- **OAuth Token과 Application Session을 구분하는 기준**
Forward-Auth에서는 proxy session cookie와 identity header를 JWT와 구분해 다룬다.
- **OAuth/OIDC 인증 패턴 선택 기준**
OAuth 처리는 edge에서 끝내고 upstream은 검증된 identity header를 사용하도록 구성한 Case다.
- **Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가**
edge가 user와 email만 전달한다는 사실이 이 질문의 출발점이다.
## 문제
앞단 proxy가 로그인을 맡으면 upstream은 OAuth를 몰라도 된다.
upstream은 `X-Auth-Request-User`를 사용자 식별에 사용한다.
이 헤더는 인증을 마친 edge가 만들 수도 있고 공격자가 요청에 직접 적어 보낼 수도 있다.
upstream이 받는 요청에서는 둘이 구분되지 않는다는 점이 문제가 된다.
backend port가 외부에 열려 있거나 Nginx가 브라우저의 헤더를 그대로 넘기면
공격자가 인증된 사용자처럼 보낼 수 있다.
client가 같은 이름의 헤더를 보낼 수 있기 때문에 upstream만으로는 `X-Auth-Request-User`가 edge에서 생성됐는지 판단할 수 없다.
## 결론
헤더를 믿으려면 서로 독립된 곳에서 방어를 해야 된다.
host port 닫힘 : 외부에서 upstream·proxy로 바로 가는 경로를 막는다
Nginx header 덮어쓰기 : client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다
upstream internal token : edge를 거치지 않은 내부 요청을 막는다
network isolation만으로는 내부 workload나 잘못된 proxy 헤더가 신뢰되는 문제를 막지 못한다.
backend의 internal credential 검증과 network 수준의 직접 접근 차단은 각각 별도로 적용한다.
## 검증 환경
Keycloak 26.7.0, oauth2-proxy 7.15.2
client : edge-proxy
confidential, PKCE S256 : o
외부 공개
Nginx : 8088
app 8081, oauth2-proxy 4180 : Compose network에 expose만, host publish x
Nginx
auth_request /oauth2/auth
location = /oauth2/auth : internal
auth_request_set으로 user, email, Set-Cookie 복사
client 제공 동명 헤더 : 덮어쓰기
trusted proxy : 단일 IP
upstream
EdgeIdentityController.currentUser(HttpServletRequest)
X-Internal-Auth-Token 비교 : MessageDigest.isEqual
SecurityConfig의 /edge/** : permitAll
AP4_SESSION
HttpOnly : true
SameSite : Lax
Secure : false in local HTTP fixture
expire : 1 hour in proxy configuration
session-cookie-minimal : true
server-side session store : x
automatic discovery : x
login, token, JWKS, userinfo URL을 각각 관리.
HTTP : o
## 재현 조건
1. cookie 없이 GET /를 부르면 /oauth2/start로 302가 되는지 확인.
2. cookie 없이 GET /api/edge를 부르면 Location 없는 401이 되는지 확인.
3. authorization request에 client_id=edge-proxy와 code_challenge_method=S256이 있는지 확인.
4. 로그인 뒤 cookie가 AP4_SESSION이며 HttpOnly와 SameSite=Lax인지 확인.
브라우저 요청 목록에 Keycloak token endpoint가 없어야 함.
Web Storage가 비어 있고 document.cookie로 session cookie를 읽을 수 없어야 함.
5. 정상 session에 다음 헤더를 얹어 GET /api/edge를 보냄.
X-Auth-Request-User : spoofed-admin
X-Auth-Request-Email : spoofed-admin@example.test
X-Internal-Auth-Token : attacker-controlled-token
응답은 200이고 user는 spoofed-admin이 아니라 실제 authenticated user여야 함.
6. 외부에서 GET /oauth2/auth를 부르면 404인지 확인.
7. host의 4180과 8081에 접근할 수 없는지 확인.
8. 내부에서 /edge/me를 부를 때 user 헤더만 있거나 internal token이 없거나 틀리면 401이고,
둘 다 맞으면 200인지 확인.
## 본문
<!-- body:start -->
## 같은 이름의 헤더
:::evidence key="ap4-edge-trust-1cff2399" alt="왼쪽 외부 영역의 브라우저에 AP4_SESSION과 점선으로 표시된 client 제공 header가 있다. 가운데 Nginx는 8088만 공개하고 header 덮어쓰기를 맡는다. 오른쪽 점선 영역은 host port가 닫혀 있고 oauth2-proxy와 Spring upstream이 들어 있다. Nginx가 oauth2-proxy에 auth_request를 보내 user와 email을 받고, nginx-owned header와 internal token으로 upstream 요청을 만든다." caption="" zoom="true"
:::
`X-Auth-Request-User`는 인증을 마친 edge가 만들 수도 있고 공격자가 직접 적어 보낼 수도 있다.
client가 같은 이름의 헤더를 보낼 수 있기 때문에 upstream만으로는 `X-Auth-Request-User`가 edge에서 생성됐는지 판단할 수 없다.
## 위조 요청의 모양
로그인을 마친 브라우저가 정상 요청에 세 헤더를 넣었다고 하자.
```http label="공격자가 보낸 요청"
GET http://localhost:8088/api/edge
Cookie: AP4_SESSION=<opaque-session>
X-Auth-Request-User: spoofed-admin
X-Auth-Request-Email: spoofed-admin@example.test
X-Internal-Auth-Token: attacker-controlled-token
```
이 테스트의 assertion은 status code가 아니다. 정상 session을 함께 보냈으니 요청 자체는 200이 될 수 있다. 확인할 값은 응답의 `user`가 `spoofed-admin`으로 바뀌지 않았는지다.
## 세 개의 독립된 경계
현재 OAuth2-Proxy 구성에서는 세 단계에서 위조 요청을 차단한다.
| 위치 | 차단 대상 |
|---|---|
| host port 닫힘 | 외부에서 upstream·proxy로 가는 직접 경로 |
| Nginx header 덮어쓰기 | client가 보낸 동명 헤더 |
| upstream internal token | edge를 거치지 않은 내부 요청 |
host port를 외부에 열면 edge를 거치지 않고 backend에 접근할 수 있다. Nginx가 동명 헤더를 덮어쓰지 않으면 client가 보낸 identity 값이 upstream에 전달될 수 있다. backend의 internal credential 검증은 edge를 거치지 않은 내부 요청을 구분하는 데 사용한다.
network isolation과 internal credential 검증은 서로 다른 요청 경로를 통제하므로 둘 다 적용한다.
## Nginx가 헤더를 만드는 경계
Nginx는 먼저 internal subrequest를 만든다.
`location = /oauth2/auth`는 `internal`이라 Nginx가 만든 subrequest만 들어갈 수 있다.
```nginx label="upstream을 부르기 전에 먼저 물어본다"
auth_request /oauth2/auth;
```
oauth2-proxy가 session을 유효하다고 판단하면 결과를 헤더로 돌려준다. Nginx는 그 값을 지역 변수로 복사한다.
```text label="auth_request_set — 값의 출처가 여기서 고정"
$auth_user ← oauth2-proxy X-Auth-Request-User
$auth_email ← oauth2-proxy X-Auth-Request-Email
$auth_cookie ← oauth2-proxy Set-Cookie
```
그 다음 원래 요청을 그대로 넘기지 않는다. 외부 `/api/edge`는 내부 `/edge/me`로 다시 매핑되고, 세 헤더는 **merge가 아니라 덮어쓰기**로 채워진다.
```http label="upstream이 실제로 받는 요청"
GET http://app:8081/edge/me
X-Auth-Request-User: <oauth2-proxy-authenticated-user>
X-Auth-Request-Email: <oauth2-proxy-authenticated-email>
X-Internal-Auth-Token: <nginx-environment-secret>
```
그럼 client가 무엇을 보냈든 upstream 입력은 oauth2-proxy가 확인한 값이 된다.
## upstream은 무엇을 확인하나
`EdgeIdentityController.currentUser(HttpServletRequest)`가 `/edge/me`를 받는다.
1. `X-Auth-Request-User`를 읽고 비어 있는지 확인한다.
2. `X-Internal-Auth-Token`을 읽어 설정값과 `MessageDigest.isEqual`로 비교한다.
두 조건이 모두 맞을 때만 allowlist한 field를 응답에 넣는다.
```json label="정상 응답 — 4가지 필드"
{
"pattern": "AP4-edge-forward-auth",
"user": "regular-user",
"email": "regular-user@example.test",
"identityHeader": "X-Auth-Request-User"
}
```
하나라도 다르면 401이 된다.
```json label="user 헤더가 없거나 internal token이 틀릴 때"
{
"error": "trusted edge authentication is required"
}
```
internal token은 `MessageDigest.isEqual`로 비교했다. 문자열을 앞에서부터 비교하다 중단하는 방식보다 입력에 따른 비교 시간 차이를 줄이기 위한 선택이다.
:::danger
현재 `SecurityConfig`는 `/edge/**`를 `permitAll`로 두고 `/edge/me` controller가 직접 internal token을 확인한다. 새 edge endpoint를 추가하면서 같은 메서드를 부르지 않으면 보호 되지 않는다.
:::
운영에서는 controller마다 같은 검사를 반복하지 않도록 filter, interceptor, security chain 등 공통 경로에서 검증하도록 구성해야 한다.
## 경로마다 달라지는 결과
같은 미인증 요청이라도 경로에 따라 다른 응답이 나온다.
| 외부 입력 | 인증 상태 | 결과 |
|---|---|---|
| `GET /` | 미인증 | `/oauth2/start` 302 |
| `GET /api/edge` | 미인증 | redirect 없는 401 |
| `GET /oauth2/auth` | 무관 | 404 |
| `GET /` + 위조 헤더 | 정상 session | 실제 user 200 |
| `/edge/me` + user 헤더만 | edge token 없음 | 401 |
| `/edge/me` + 틀린 token | token 불일치 | 401 |
아래 두 줄은 내부에서 들어온 요청이다. 첫 줄과 둘째 줄이 다른 이유는 화면을 여는 요청과 프로그램이 부르는 요청이 원하는 실패 구조가 다르기 때문이다. 사람은 로그인 화면으로 가야 하고, 프로그램은 `Location` 없는 401을 받아야 한다.
**redirect 없는 JSON 401은 정확히 `/api/edge` 경로에만 구성돼 있다.**
다른 경로는 로그인 redirect 규칙을 따른다.
셋째 줄도 중요하다. 외부에서 `/oauth2/auth`를 직접 부르면 404다. `internal` 지정이 없으면 이 endpoint가 밖에서 부를 수 있는 인증 우회 지점이 된다.
## 브라우저가 가지고 있는 것
OAuth2-Proxy 구조는 server-side session store를 두지 않는다.
```text label="AP4_SESSION cookie 설정"
name = AP4_SESSION
HttpOnly = true
SameSite = Lax
Secure = false in local HTTP fixture
expire = 1 hour in proxy configuration
```
`session-cookie-minimal=true`에서는 oauth2-proxy가 필요한 최소 session 정보만 cookie에 저장한다. 이 cookie는 HttpOnly로 설정되어 JavaScript에서 읽지 않고, 브라우저가 다음 요청에 자동으로 전송한다.
지금 값은 local HTTP fixture 기준이다. HTTPS로 올리면 `Secure = true`로 바꿔야 한다. replica를 늘린다면 같은 cookie를 검증할 secret을 어떻게 배포하고 교체할지도 정해야 한다.
## endpoint를 외부용과 내부용으로 나눈 이유
브라우저가 도달해야 하는 주소와 container가 도달해야 하는 주소가 다르다.
이 구성에서는 자동 discovery를 사용하지 않고 필요한 endpoint 주소를 각각 지정한다.
```text label="issuer는 브라우저가 접속하는 부분"
issuer expected value = http://localhost:8080/realms/keycloak-patterns
login URL = http://localhost:8080/.../auth
redeem/token URL = http://keycloak:8080/.../token
JWKS/userinfo URL = http://keycloak:8080/...
```
issuer는 실제로 요청을 보내기 위한 주소가 아니라 KeyCloak이 발급한 토큰의 iss claim이 우리가 기대한 값과 같은지 검증하기 위한 기준값이다. 반면 token url, userinfo url 같은 경우는 실제로 내부에서 oauth2-proxy가 요청을 보내기 위해 사용되는 내부 네트워크 주소다.
따라서 둘다 keycloak realm을 가리키지만 용도가 다르고 브라우저는 docker 내부 호스트명인 `keycloak:8080`에 접근할 수 없기에 로그인에는 `localhost:8080`을 사용하고 컨테이너는 자신의 `localhost:8080`이 keycloak이 아니므로 내부 통신에는 `keycloak:8080`을 사용한다.
## upstream이 JWT를 받지 않는다
앞의 3가지 구조에서는 Resource Server는 JWT의 서명과 issuer, audience를 직접 확인한다. OAuth2-Proxy 구조의 `/edge/me`는 **JWT를 입력으로 받지 않는다.**
| 무엇을 믿나 | AP1~AP3 | AP4 |
|---|---|---|
| 서명된 JWT | o | x |
| network topology | x | o |
| internal token | x | o |
| edge의 user·email | x | o |
upstream은 edge가 검증한 결과와 edge가 추가한 헤더를 신뢰한다. 따라서 backend 직접 접근과 client가 보낸 동명 identity header를 차단하는 설정이 이 구조의 전제다.
## 헤더를 늘릴 때 정해야 하는 것
현재 edge 응답은 user와 email만 전달한다. role, groups, tenant, 인증 방식, token 만료는 전달하지 않는다. 금지하는 것은 아니지만, 헤더를 늘릴 때마다 계약을 정해야 한다.
- claim 출처 : oauth2-proxy나 별도 auth service가 어느 값을 읽는가
- allowlist : Nginx가 어느 응답 헤더만 복사하는가
- 덮어쓰기 : client가 보낸 동명 헤더를 항상 지우거나 덮어쓰는가
- 직렬화 : 다중 값, 구분자, escaping, 최대 크기는 무엇인가
- upstream 검증 : 헤더 존재만 볼지 값과 service identity까지 볼지
- 갱신 : role이 바뀌면 proxy session과 downstream 인가가 언제 따라가는가
## 확인한 것과 확인하지 않은 것
아래는 **커밋된 자동 테스트가 확인하도록 정의한 부분** 이다.
| 항목 | 확인한 부분 |
|---|---|
| cookie 없는 root의 302 | o |
| cookie 없는 `/api/edge`의 401 | o |
| `edge-proxy` + S256 challenge | o |
| `AP4_SESSION` HttpOnly · SameSite=Lax | o |
| 브라우저 요청에 token endpoint 없음 | o |
| Web Storage 비어 있고 cookie 읽기 불가 | o |
| 위조 헤더를 보내도 실제 user로 200 | o |
| 외부 `/oauth2/auth` 404 | o |
| host의 4180 · 8081 접근 불가 | o |
| user 헤더 없음 · token 없음 · token 불일치 401 | o |
| role 전달 | x |
| 새 endpoint의 공통 강제 | x |
| 상태 변경 요청의 CSRF | x |
| session 갱신 | x |
| replica 간 secret 공유 | x |
| internal secret 교체 | x |
일곱째 줄이 핵심이다. 요청이 실패하는지 보는 것이 아니라, **Nginx가 client 입력을 덮어쓰고 정상 identity를 반환하는지**를 본다.
## 증명하지 않는 것
현재 설정은 `/api/edge`와 `/` 요청을 모두 `/edge/me`로 전달한다. `/orders/123` 같은 임의 경로를 보존하는 범용 reverse proxy는 검증하지 않았으며 path, method, body, streaming, websocket 동작도 이번 Case의 검증 범위에 포함하지 않았다.
<!-- body:end -->
@@ -0,0 +1,110 @@
## 브라우저가 직접 다루는 credential
:::evidence key="ap1-custody-v3-6e0376d2" alt="브라우저 실행 영역 안에 code 교환, access·refresh·ID token 보관, Authorization 헤더 조립 세 상자가 들어 있고 그 영역 전체가 실행 중 XSS가 닿는 범위로 표시된 그림. Keycloak과 Resource Server는 그 밖에 있다." caption=" " zoom="true"
:::
code 교환, token 보관, `Authorization` 헤더 조립이 모두 같은 브라우저 실행 영역에 있다. 이 영역에서 악성 script가 실행되면 세 지점 모두 영향을 받는다.
## 새로고침 전후의 브라우저 상태
oidc-client-ts의 `InMemoryWebStorage`는 로그인 결과를 브라우저의 영구 저장소가 아니라 실행 중 memory에만 둔다.
새로고침하면 JavaScript memory의 `User`와 token이 초기화되고, Local Storage와 Session Storage에서는 token 복사본을 확인하지 못했다.
아래 표는 새로고침 전후로 브라우저에서 확인되는 상태를 정리한 것이다.
| 위치 | reload 전 | reload 후 |
|---|---|---|
| JavaScript memory | `User`, access·refresh·ID token, expiry, profile | 사라짐 |
| Session Storage | redirect transaction용 state와 verifier | callback 완료 뒤 제거 |
| Local Storage | 해당 없음 | 해당 없음 |
| Keycloak origin cookie | IdP의 SSO 상태가 존재할 수 있음 | application과 별개 |
memory user가 사라진다고 Keycloak SSO까지 로그아웃되는 것은 아니다.
## memory-only가 줄이는 위험
저장 위치만으로 XSS 경계를 설명할 수는 없다. 같은 origin에서 악성 script가 실행되면 JavaScript memory와 fetch 호출 모두 같은 실행 영역에 있기 때문이다.
| 위협 | memory-only가 막아주나 |
|---|---|
| 새로고침 뒤에도 남는 token 복사본 | 막아준다 |
| 실행 중 script가 fetch를 가로채기 | 막아주지 않는다 |
| 실행 중 script가 사용자 대신 API 호출 | 막아주지 않는다 |
| network 요청 헤더에 실린 access token | 막아주지 않는다 |
| 이미 발급된 access JWT의 만료 전 유효성 | 막아주지 않는다 |
네 번째 줄이 이 코드에서 access token이 외부 요청으로 나가는 지점이다. SPA는 요청마다 이 헤더를 만든다.
```http label="브라우저가 Resource Server를 직접 부를 때"
GET http://localhost:8081/api/me
Authorization: Bearer <access-token>
```
token 원문은 memory에도 있고 network 헤더에도 실린다.
Resource Server가 `SessionCreationPolicy.STATELESS`라서 서버에 지울 session이 없다.
이미 발급된 self-contained JWT를 logout 순간에 즉시 없앨 방법이 없고, logout은 Keycloak SSO 종료와 애플리케이션 user 제거를 다룰 뿐 access JWT를 deny-list에서 관리하지 않는다.
이 구성에서는 access token의 만료 시간을 짧게 두어 노출됐을 때 사용할 수 있는 시간을 제한한다.
access token : 300초
refresh token rotation, 재사용 허용 : x
issuer·audience : 검증
Local Storage나 Session Storage로 옮기면 새로고침은 편해지지만 노출 시간이 길어진다.
HttpOnly cookie로 옮기는 일은 저장 위치만 바꾸는 작업이 아니다.
server가 session이나 token 중계를 맡는 구조가 필요하다.
## PKCE가 막는 구간
PKCE(Proof Key for Code Exchange)는 authorization request에 `code_challenge`를 싣고, code를 token으로 바꿀 때 원본인 `code_verifier`를 같이 보내게 한다. 둘이 맞아야 교환이 끝난다.
```text label="oidc-client-ts가 만드는 authorization request의 핵심 query"
response_type=code
client_id=spa-public
redirect_uri=http://localhost:8088/OAuth2callback.html
scope=openid profile email
state=<opaque-state>
code_challenge=<opaque-challenge>
code_challenge_method=S256
```
`response_type=code`가 Authorization Code Flow를 쓴다는 뜻이고, `code_challenge`와 `code_challenge_method=S256`이 PKCE 사용을 나타낸다.
막는 구간은 code 교환까지다. 이미 발급된 access token은 막아주지 않는다.
## 확인한 것과 확인하지 않은 것
아래는 **커밋된 테스트가 확인하도록 정의한 부분**이다. 왼쪽이 정의 여부, 오른쪽이 정의 내용.
| 정의 여부 | 정의 내용 |
|---|---|
| o | authorization request의 `response_type=code`, S256 method, 비어 있지 않은 challenge |
| o | token 응답에 비어 있지 않은 access·refresh·ID token |
| o | `/api/me` 200과 decoded access token의 audience 포함 |
| o | 브라우저 fetch를 가로채 Authorization 헤더의 Bearer token 관측 |
| o | Local Storage와 Session Storage에 access token substring 없음 |
| o | refresh rotation — 새 token 발급, 이전 token 거부, revocation 뒤 refresh 실패 |
| o | issuer나 audience가 다른 진단용 서버 두 곳의 401 |
| x | token request body의 `code_verifier`·`client_id`·`redirect_uri`·code 값 대조 |
| x | 서명이 깨진 JWT, 만료된 JWT |
| x | 브라우저 간 요청(CORS)의 preflight 응답 |
| x | callback에 error가 실려 돌아왔을 때의 화면 |
| x | `automaticSilentRenew`의 실제 갱신 경로 |
첫 줄과 여덟째 줄을 같이 보자.
**authorization request의 파라미터를 보는 것이지 PKCE 교환이 성립하는 것을 보는 것이 아니다.**
:::warning
SPA는 non-2xx 응답에서도 `response.ok`을 확인하기 전에 `response.json()`을 시도한다. 401 body가 비어 있거나 JSON이 아니면 의도한 오류 처리보다 JSON parse error가 먼저 발생한다.
:::
## 추가로 설정에서 확인해야될 것
local realm의 redirect allowlist는
`http://localhost:8088/*`와 `http://127.0.0.1:8088/*` wildcard다.
SPA : `/OAuth2callback.html`만 o,
exact callback만 허용하는 운영적 측면 또는 잘못된 redirect를 거부하는 검사는 x
frontend Nginx에도 `/api/` proxy가 있지만 SPA는 상대 URL이 아니라 absolute `http://localhost:8081/api/me`를 사용한다. 브라우저는 8088에서 8081로 cross-origin 요청을 보내므로 Resource Server의 CORS allowlist가 실제 요청에 적용된다.
상대 URL을 썼다면 이 경계에서 확인되는 부분은 없었을 것이다.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,200 @@
---
id: bf675775-4f3e-4744-8014-f0efff51422a
kind: CASE
slug: spa-browser-credential-boundary
title: SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 25
verifiedOn: 2026-08-22
studio: "https://hyeonworks.com/studio/documents/bf675775-4f3e-4744-8014-f0efff51422a/edit"
public: "https://hyeonworks.com/cases/spa-browser-credential-boundary"
---
# SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계
SPA를 public OAuth client로 구성해 authorization code를 직접 교환하고, access·refresh·ID token은 JavaScript memory에 보관했다. Web Storage에는 token을 저장하지 않았고, 실행 중인 script가 같은 JavaScript 실행 영역의 token과 API 호출에 접근할 수 있는지도 함께 확인했다.
## 관계
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
브라우저가 authorization endpoint와 token endpoint를 직접 호출하는 흐름을 코드와 network 요청으로 확인했다.
- **Public Client와 Confidential Client 구분 기준**
SPA는 client secret을 안전하게 보관할 수 없어 public client로 등록했고, Authorization Code Flow에는 PKCE를 적용했다.
- **OAuth Token과 Application Session을 구분하는 기준**
JavaScript memory의 OAuth token과 Keycloak 도메인의 SSO cookie가 서로 다른 상태라는 점을 확인했다.
- **인증 구조를 보안 성숙도 단계로 취급하지 않는다**
이 Case의 SPA 구성을 다른 패턴보다 낮은 단계로 해석하지 않도록 별도의 결정 기록에서 기준을 정했다.
## 문제
token을 Web Storage에 저장하지 않고 JavaScript memory에만 보관했을 때 XSS 경계가 어떻게 달라지는지 확인할 필요가 있었다.
AP1은 SPA가 public client가 되어 authorization code를 직접 교환하고 access·refresh·ID token을 JavaScript memory에 두는 구성이다.
확인할 내용은 세 가지였다. 브라우저가 어떤 credential을 직접 다루는지, PKCE가 어떤 공격을 막는지, memory-only 보관으로 제한할 수 있는 위험이 무엇인지였다.
## 결론
memory-only 보관은 token을 Web Storage에 지속적으로 저장하지 않는 방법이다. 실행 중 XSS가 같은 JavaScript 실행 영역에 접근하는 문제까지 해결하지는 않는다.
실행 중 악성 script는 같은 화면에서 fetch를 가로채거나 사용자를 대신해 API를 부를 수 있다.
token 원문은 memory에만 있는 것이 아니라 요청마다 Authorization 헤더에도 실리기 때문에 노출된다.
Resource Server는 STATELESS로 동작하고 별도 session이나 denylist를 두지 않았다. 이미 발급된 self-contained JWT는 logout만으로 즉시 무효화되지 않으므로 짧은 만료 시간과 refresh token rotation을 사용하고, Resource Server에서는 issuer와 audience를 검증한다.
PKCE는 훔친 authorization code의 교환을 막을 뿐이지 발급된 access token을 숨기지 않는다.
## 검증 환경
Keycloak 26.7.0
realms 설정
public-client, standard flow : o
implicit flow, direct grant : x
authority : http://localhost:8080/realms/keycloak-patterns
redirect_uri : http://localhost:8088/OAuth2callback.html
scope : openid profile email
userStore : InMemoryWebStorage
stateStore : sessionStorage
automaticSilentRenew : true
Resource Server
SessionCreationPolicy.STATELESS
CSRF x
CORS allowlist : localhost:8088, 127.0.0.1:8088, GET·OPTIONS, Authorization·Content-Type
HTTPS : x
HTTP : o
## 재현 조건
1. SPA를 열고 로그인후 Keycloak authorization request의 response_type=code, code_challenge_method=S256, 비어 있지 않은 code_challenge를 확인.
2. token 응답에 access·refresh·ID token이 비어 있지 않은지 확인.
3. 브라우저 fetch를 hook해 /api/me 호출의 Authorization header에서 Bearer access token을 확인.
4. Local Storage와 Session Storage에 access token substring이 남지 않는지 확인.
5. 같은 정상 JWT를 expected issuer·audience가 다른 diagnostic server 두 곳에 제출해 401을 확인.
6. refresh token으로 새 token을 받고 이전 refresh token이 거부되는지, revocation 뒤 refresh가 실패하는지, 이미 발급된 access JWT가 만료 전까지 200인지 확인.
## 본문
<!-- body:start -->
## 브라우저가 직접 다루는 credential
:::evidence key="ap1-custody-v3-6e0376d2" alt="브라우저 실행 영역 안에 code 교환, access·refresh·ID token 보관, Authorization 헤더 조립 세 상자가 들어 있고 그 영역 전체가 실행 중 XSS가 닿는 범위로 표시된 그림. Keycloak과 Resource Server는 그 밖에 있다." caption=" " zoom="true"
:::
code 교환, token 보관, `Authorization` 헤더 조립이 모두 같은 브라우저 실행 영역에 있다. 이 영역에서 악성 script가 실행되면 세 지점 모두 영향을 받는다.
## 새로고침 전후의 브라우저 상태
oidc-client-ts의 `InMemoryWebStorage`는 로그인 결과를 브라우저의 영구 저장소가 아니라 실행 중 memory에만 둔다.
새로고침하면 JavaScript memory의 `User`와 token이 초기화되고, Local Storage와 Session Storage에서는 token 복사본을 확인하지 못했다.
아래 표는 새로고침 전후로 브라우저에서 확인되는 상태를 정리한 것이다.
| 위치 | reload 전 | reload 후 |
|---|---|---|
| JavaScript memory | `User`, access·refresh·ID token, expiry, profile | 사라짐 |
| Session Storage | redirect transaction용 state와 verifier | callback 완료 뒤 제거 |
| Local Storage | 해당 없음 | 해당 없음 |
| Keycloak origin cookie | IdP의 SSO 상태가 존재할 수 있음 | application과 별개 |
memory user가 사라진다고 Keycloak SSO까지 로그아웃되는 것은 아니다.
## memory-only가 줄이는 위험
저장 위치만으로 XSS 경계를 설명할 수는 없다. 같은 origin에서 악성 script가 실행되면 JavaScript memory와 fetch 호출 모두 같은 실행 영역에 있기 때문이다.
| 위협 | memory-only가 막아주나 |
|---|---|
| 새로고침 뒤에도 남는 token 복사본 | 막아준다 |
| 실행 중 script가 fetch를 가로채기 | 막아주지 않는다 |
| 실행 중 script가 사용자 대신 API 호출 | 막아주지 않는다 |
| network 요청 헤더에 실린 access token | 막아주지 않는다 |
| 이미 발급된 access JWT의 만료 전 유효성 | 막아주지 않는다 |
네 번째 줄이 이 코드에서 access token이 외부 요청으로 나가는 지점이다. SPA는 요청마다 이 헤더를 만든다.
```http label="브라우저가 Resource Server를 직접 부를 때"
GET http://localhost:8081/api/me
Authorization: Bearer <access-token>
```
token 원문은 memory에도 있고 network 헤더에도 실린다.
Resource Server가 `SessionCreationPolicy.STATELESS`라서 서버에 지울 session이 없다.
이미 발급된 self-contained JWT를 logout 순간에 즉시 없앨 방법이 없고, logout은 Keycloak SSO 종료와 애플리케이션 user 제거를 다룰 뿐 access JWT를 deny-list에서 관리하지 않는다.
이 구성에서는 access token의 만료 시간을 짧게 두어 노출됐을 때 사용할 수 있는 시간을 제한한다.
access token : 300초
refresh token rotation, 재사용 허용 : x
issuer·audience : 검증
Local Storage나 Session Storage로 옮기면 새로고침은 편해지지만 노출 시간이 길어진다.
HttpOnly cookie로 옮기는 일은 저장 위치만 바꾸는 작업이 아니다.
server가 session이나 token 중계를 맡는 구조가 필요하다.
## PKCE가 막는 구간
PKCE(Proof Key for Code Exchange)는 authorization request에 `code_challenge`를 싣고, code를 token으로 바꿀 때 원본인 `code_verifier`를 같이 보내게 한다. 둘이 맞아야 교환이 끝난다.
```text label="oidc-client-ts가 만드는 authorization request의 핵심 query"
response_type=code
client_id=spa-public
redirect_uri=http://localhost:8088/OAuth2callback.html
scope=openid profile email
state=<opaque-state>
code_challenge=<opaque-challenge>
code_challenge_method=S256
```
`response_type=code`가 Authorization Code Flow를 쓴다는 뜻이고, `code_challenge`와 `code_challenge_method=S256`이 PKCE 사용을 나타낸다.
막는 구간은 code 교환까지다. 이미 발급된 access token은 막아주지 않는다.
## 확인한 것과 확인하지 않은 것
아래는 **커밋된 테스트가 확인하도록 정의한 부분**이다. 왼쪽이 정의 여부, 오른쪽이 정의 내용.
| 정의 여부 | 정의 내용 |
|---|---|
| o | authorization request의 `response_type=code`, S256 method, 비어 있지 않은 challenge |
| o | token 응답에 비어 있지 않은 access·refresh·ID token |
| o | `/api/me` 200과 decoded access token의 audience 포함 |
| o | 브라우저 fetch를 가로채 Authorization 헤더의 Bearer token 관측 |
| o | Local Storage와 Session Storage에 access token substring 없음 |
| o | refresh rotation — 새 token 발급, 이전 token 거부, revocation 뒤 refresh 실패 |
| o | issuer나 audience가 다른 진단용 서버 두 곳의 401 |
| x | token request body의 `code_verifier`·`client_id`·`redirect_uri`·code 값 대조 |
| x | 서명이 깨진 JWT, 만료된 JWT |
| x | 브라우저 간 요청(CORS)의 preflight 응답 |
| x | callback에 error가 실려 돌아왔을 때의 화면 |
| x | `automaticSilentRenew`의 실제 갱신 경로 |
첫 줄과 여덟째 줄을 같이 보자.
**authorization request의 파라미터를 보는 것이지 PKCE 교환이 성립하는 것을 보는 것이 아니다.**
:::warning
SPA는 non-2xx 응답에서도 `response.ok`을 확인하기 전에 `response.json()`을 시도한다. 401 body가 비어 있거나 JSON이 아니면 의도한 오류 처리보다 JSON parse error가 먼저 발생한다.
:::
## 추가로 설정에서 확인해야될 것
local realm의 redirect allowlist는
`http://localhost:8088/*`와 `http://127.0.0.1:8088/*` wildcard다.
SPA : `/OAuth2callback.html`만 o,
exact callback만 허용하는 운영적 측면 또는 잘못된 redirect를 거부하는 검사는 x
frontend Nginx에도 `/api/` proxy가 있지만 SPA는 상대 URL이 아니라 absolute `http://localhost:8081/api/me`를 사용한다. 브라우저는 8088에서 8081로 cross-origin 요청을 보내므로 Resource Server의 CORS allowlist가 실제 요청에 적용된다.
상대 URL을 썼다면 이 경계에서 확인되는 부분은 없었을 것이다.
<!-- body:end -->
@@ -0,0 +1,19 @@
{
"kind": "PROJECT_DECISION",
"title": "BFF가 OAuth Token을 관리하는 조건",
"slug": "bff-owns-token-when-browser-must-not",
"summary": "애플리케이션이 API 조합과 인가를 직접 처리하면서 브라우저에는 OAuth token을 전달하지 않아야 한다면 BFF가 authorization code 교환, token 보관, downstream 호출을 담당한다. 이 결정은 아직 프로젝트 기본값으로 채택하지 않아 `PROPOSED` 상태로 둔다.",
"decisionStatus": "PROPOSED",
"decidedOn": null,
"statement": "브라우저에 OAuth token을 노출하지 않으면서 애플리케이션이 Resource Server 호출을 중계하고 조합해야 하는 경우, BFF가 authorization code 교환과 token 보관, downstream API 호출을 소유한다.\n\n브라우저에는 애플리케이션 session만 제공한다.",
"rationale": "브라우저에 OAuth token을 전달하지 않으려면 server가 authorization code를 교환하고 access token을 사용해 downstream API를 호출해야 한다.\n\nMediator 구조에서는 브라우저가 Resource Server를 직접 호출하므로 access token을 `/token/access` 응답으로 전달한다. 따라서 브라우저에 OAuth token을 제공하지 않는다는 요구에는 맞지 않는다.\n\nForward-Auth 구조도 브라우저에 OAuth token을 전달하지 않을 수 있지만 upstream은 JWT를 직접 검증하지 않고 edge가 제공한 identity header를 사용한다. 애플리케이션이 access token으로 여러 Resource Server를 직접 호출하거나 사용자별 API 조합을 처리해야 한다면 BFF 쪽이 요구에 더 잘 맞는다.\n\n따라서 브라우저에 OAuth token을 전달하지 않는 조건만으로 BFF를 선택하지는 않는다. 애플리케이션이 downstream API 호출과 조합을 직접 맡아야 하는지도 함께 본다.\n\n다만 상태를 ADOPTED로 올리지는 않는다. 지금 자료는 네 구조를 나란히 실행한 비교 실험이고 이 프로젝트가 BFF를 기본값으로 고른 기록이 없기 때문이다. 기본값으로 고른 시점과 그 근거가 생기면 그때 올리게 되고, 그 전까지 실제 적용 기준은 「BFF 인증 구조 설계 기준」 Reference다.",
"consequences": [
"BFF가 로그인 상태와 token을 가진 보안 구성요소가 되어서 단순 proxy로 취급할 수 없게 된다.",
"상태 변경 요청마다 CSRF 검증이 필요해지고, 노출 값과 제출 값이 다를 수 있어서 클라이언트 코드도 그 구분을 알아야 한다.",
"재시작과 replica 이동을 견딜 공유 저장소와 저장 token 암호화, 암호화 key 교체를 설계해야 하는데 아직 정하지 않은 문제로 남아 있다.",
"logout이 애플리케이션 session과 authorized client를 함께 지워야 하는데, 열쇠가 달라서 한 번의 삭제로 두 상태가 함께 지워지지 않는다.",
"모든 UI 요청이 BFF를 지나게 되어서 지연과 단일 장애 지점을 준비해야 한다.",
"브라우저에서 token을 없애도 XSS가 무해해지지 않고, same-origin script는 피해자 session으로 BFF를 그대로 부를 수 있다.",
"이 결정이 PROPOSED인 동안은 「BFF 인증 구조 설계 기준」 Reference가 실제 적용 기준이다."
]
}
@@ -0,0 +1,55 @@
---
id: 19b55c39-c583-4161-9775-df954280a568
kind: PROJECT_DECISION
slug: bff-owns-token-when-browser-must-not
title: BFF가 OAuth Token을 관리하는 조건
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 전
version: 11
decisionStatus: PROPOSED
studio: "https://hyeonworks.com/studio/documents/19b55c39-c583-4161-9775-df954280a568/edit"
---
# BFF가 OAuth Token을 관리하는 조건
애플리케이션이 API 조합과 인가를 직접 처리하면서 브라우저에는 OAuth token을 전달하지 않아야 한다면 BFF가 authorization code 교환, token 보관, downstream 호출을 담당한다. 이 결정은 아직 프로젝트 기본값으로 채택하지 않아 `PROPOSED` 상태로 둔다.
## 근거
- **BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정**
이 결정이 가리키는 구조를 실제로 실행해 본 기록이다.
- **BFF 인증 구조 설계 기준**
이 결정이 PROPOSED인 동안의 실제 적용 기준이다.
- **OAuth/OIDC 인증 패턴 선택 기준**
이 결정을 적용할 조건과 피해야 할 조건이 여기 있다.
- **Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조**
access token이 브라우저로 나가 이 요구를 만족하지 못한 경우다.
## 결정문
브라우저에 OAuth token을 노출하지 않으면서 애플리케이션이 Resource Server 호출을 중계하고 조합해야 하는 경우, BFF가 authorization code 교환과 token 보관, downstream API 호출을 소유한다.
브라우저에는 애플리케이션 session만 제공한다.
## 판단 이유
브라우저에 OAuth token을 전달하지 않으려면 server가 authorization code를 교환하고 access token을 사용해 downstream API를 호출해야 한다.
Mediator 구조에서는 브라우저가 Resource Server를 직접 호출하므로 access token을 `/token/access` 응답으로 전달한다. 따라서 브라우저에 OAuth token을 제공하지 않는다는 요구에는 맞지 않는다.
Forward-Auth 구조도 브라우저에 OAuth token을 전달하지 않을 수 있지만 upstream은 JWT를 직접 검증하지 않고 edge가 제공한 identity header를 사용한다. 애플리케이션이 access token으로 여러 Resource Server를 직접 호출하거나 사용자별 API 조합을 처리해야 한다면 BFF 쪽이 요구에 더 잘 맞는다.
따라서 브라우저에 OAuth token을 전달하지 않는 조건만으로 BFF를 선택하지는 않는다. 애플리케이션이 downstream API 호출과 조합을 직접 맡아야 하는지도 함께 본다.
다만 상태를 ADOPTED로 올리지는 않는다. 지금 자료는 네 구조를 나란히 실행한 비교 실험이고 이 프로젝트가 BFF를 기본값으로 고른 기록이 없기 때문이다. 기본값으로 고른 시점과 그 근거가 생기면 그때 올리게 되고, 그 전까지 실제 적용 기준은 「BFF 인증 구조 설계 기준」 Reference다.
## 영향
- BFF가 로그인 상태와 token을 가진 보안 구성요소가 되어서 단순 proxy로 취급할 수 없게 된다.
- 상태 변경 요청마다 CSRF 검증이 필요해지고, 노출 값과 제출 값이 다를 수 있어서 클라이언트 코드도 그 구분을 알아야 한다.
- 재시작과 replica 이동을 견딜 공유 저장소와 저장 token 암호화, 암호화 key 교체를 설계해야 하는데 아직 정하지 않은 문제로 남아 있다.
- logout이 애플리케이션 session과 authorized client를 함께 지워야 하는데, 열쇠가 달라서 한 번의 삭제로 두 상태가 함께 지워지지 않는다.
- 모든 UI 요청이 BFF를 지나게 되어서 지연과 단일 장애 지점을 준비해야 한다.
- 브라우저에서 token을 없애도 XSS가 무해해지지 않고, same-origin script는 피해자 session으로 BFF를 그대로 부를 수 있다.
- 이 결정이 PROPOSED인 동안은 「BFF 인증 구조 설계 기준」 Reference가 실제 적용 기준이다.
@@ -0,0 +1,16 @@
{
"kind": "PROJECT_DECISION",
"title": "외부 IdP Federation을 별도의 인증 구조로 세지 않는다",
"slug": "federation-is-not-an-application-pattern",
"summary": "Google은 upstream IdP, Keycloak은 애플리케이션이 신뢰하는 issuer이자 broker, 네 구조는 애플리케이션 credential 경계다. 세 층을 분리해서 적고 소셜 로그인 추가를 인증 구조 변경으로 세지 않는다.",
"decisionStatus": "ADOPTED",
"decidedOn": "2026-08-24",
"statement": "외부 IdP federation을 다섯 번째 인증 구조로 세지 않는다.\n\nGoogle은 upstream IdP, Keycloak은 애플리케이션이 신뢰하는 issuer이자 broker, 네 구조는 애플리케이션 credential 경계로 각각 분리해 적는다.",
"rationale": "Google을 구조 하나로 세게 되면 upstream IdP 경계와 애플리케이션 OAuth 경계를 같은 기준으로 묶게 되는데, 두 경계는 검증 방법이 서로 다르다.\n\n사용자가 Keycloak 로그인 화면에서 Google을 고르면 브라우저가 Google authorization endpoint로 이동한다. Keycloak은 Google의 응답을 검증해 local identity와 연결한 뒤 자기 authorization code를 애플리케이션 callback으로 보낸다. 이후 애플리케이션은 Google이 아니라 Keycloak을 상대로 code를 token으로 교환한다.\n\nResource Server가 검증하는 issuer도 브로커이고 애플리케이션은 Google token을 받지 않기 때문에, 소셜 로그인을 붙여도 브라우저가 token을 받는지와 어느 계층이 API를 부르는지는 하나도 바뀌지 않는다.\n\n두 경계를 섞어 두게 되면 비교표에 성격이 다른 항목이 끼어들고, 계정 연결 규칙도 인증 구조 이야기에 섞여서 따로 설계하지 않고 넘어가게 된다.",
"consequences": [
"Google을 추가해도 애플리케이션이 검증하는 issuer는 Keycloak으로 유지한다. 네 구조의 credential 배치 기준은 바뀌지 않는다.",
"계정 연결을 별도 문제로 다뤄야 하고, provider와 upstream subject의 조합을 열쇠로 쓰면서 email이 같다고 자동 병합하지 않는다.",
"검증 범위를 두 겹으로 적어야 해서 mock provider로 확인한 broker·claim mapping 계약과 실제 계정·공개 HTTPS callback·consent를 구분하게 된다.",
"upstream IdP가 늘면 브로커 설정이 늘어나게 되어서 그 설정의 소유자를 애플리케이션 팀과 따로 정해야 한다."
]
}
@@ -0,0 +1,49 @@
---
id: 8c1ebea7-204e-445c-9812-0421d9eb0e9c
kind: PROJECT_DECISION
slug: federation-is-not-an-application-pattern
title: 외부 IdP Federation을 별도의 인증 구조로 세지 않는다
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 전
version: 9
decisionStatus: ADOPTED
decidedOn: 2026-08-24
studio: "https://hyeonworks.com/studio/documents/8c1ebea7-204e-445c-9812-0421d9eb0e9c/edit"
---
# 외부 IdP Federation을 별도의 인증 구조로 세지 않는다
Google은 upstream IdP, Keycloak은 애플리케이션이 신뢰하는 issuer이자 broker, 네 구조는 애플리케이션 credential 경계다. 세 층을 분리해서 적고 소셜 로그인 추가를 인증 구조 변경으로 세지 않는다.
## 근거
- **외부 IdP Federation과 Application 인증 경계**
이 결정을 규칙으로 편 기준이다.
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
브로커가 발급한 code를 받는 애플리케이션 경계다.
- **OAuth Token과 Application Session을 구분하는 기준**
upstream IdP 상태와 애플리케이션 상태를 같은 이름으로 부르지 않는다.
## 결정문
외부 IdP federation을 다섯 번째 인증 구조로 세지 않는다.
Google은 upstream IdP, Keycloak은 애플리케이션이 신뢰하는 issuer이자 broker, 네 구조는 애플리케이션 credential 경계로 각각 분리해 적는다.
## 판단 이유
Google을 구조 하나로 세게 되면 upstream IdP 경계와 애플리케이션 OAuth 경계를 같은 기준으로 묶게 되는데, 두 경계는 검증 방법이 서로 다르다.
사용자가 Keycloak 로그인 화면에서 Google을 고르면 브라우저가 Google authorization endpoint로 이동한다. Keycloak은 Google의 응답을 검증해 local identity와 연결한 뒤 자기 authorization code를 애플리케이션 callback으로 보낸다. 이후 애플리케이션은 Google이 아니라 Keycloak을 상대로 code를 token으로 교환한다.
Resource Server가 검증하는 issuer도 브로커이고 애플리케이션은 Google token을 받지 않기 때문에, 소셜 로그인을 붙여도 브라우저가 token을 받는지와 어느 계층이 API를 부르는지는 하나도 바뀌지 않는다.
두 경계를 섞어 두게 되면 비교표에 성격이 다른 항목이 끼어들고, 계정 연결 규칙도 인증 구조 이야기에 섞여서 따로 설계하지 않고 넘어가게 된다.
## 영향
- Google을 추가해도 애플리케이션이 검증하는 issuer는 Keycloak으로 유지한다. 네 구조의 credential 배치 기준은 바뀌지 않는다.
- 계정 연결을 별도 문제로 다뤄야 하고, provider와 upstream subject의 조합을 열쇠로 쓰면서 email이 같다고 자동 병합하지 않는다.
- 검증 범위를 두 겹으로 적어야 해서 mock provider로 확인한 broker·claim mapping 계약과 실제 계정·공개 HTTPS callback·consent를 구분하게 된다.
- upstream IdP가 늘면 브로커 설정이 늘어나게 되어서 그 설정의 소유자를 애플리케이션 팀과 따로 정해야 한다.
@@ -0,0 +1,15 @@
{
"kind": "PROJECT_DECISION",
"title": "인증 구조를 보안 성숙도 단계로 취급하지 않는다",
"slug": "patterns-are-not-a-maturity-ladder",
"summary": "SPA, Mediator, BFF, Forward-Auth는 credential을 처리하는 주체와 API 호출 경로가 서로 다르다. 번호나 브라우저 token 노출 여부를 보안 등급으로 사용하지 않고 각각 별도의 아키텍처 패턴으로 취급한다.",
"decisionStatus": "ADOPTED",
"decidedOn": "2026-08-24",
"statement": "SPA에서 Mediator, BFF, OAuth2-Proxy로 가는 순서를 낮은 보안에서 높은 보안으로 가는 단계로 모델링하지 않는다.\n\n네 구조는 credential과 인증 상태를 처리하는 주체가 서로 다른 별개의 아키텍처 패턴으로 취급한다.",
"rationale": "BFF는 브라우저 token을 없애지만 server session과 CSRF, 공유 저장소를 만든다. Forward-Auth는 애플리케이션의 token custody를 줄이지만 edge 헤더 신뢰와 network 경계를 만든다. 뒤 구조가 앞 구조의 문제를 없애는 것이 아니라 다른 곳에 다른 요구를 만든다.\n\n네 구조의 차이는 code를 교환하는 주체, token 저장 방식, API 호출 주체, 보호 자원이 신뢰하는 credential에서 확인됐다. 이 차이를 보안 성숙도 순서로 환산하지 않는다.\n\n성숙도 모델로 두면 「일단 제일 뒤 구조로 가자」는 판단이 나온다. backend 직접 경로를 닫을 수 없는 환경에서 edge에 인증을 맡기면 upstream이 헤더 하나로 사용자를 판단하는데 그 헤더를 누구나 만들어 보낼 수 있다. 그런 환경에서는 브라우저가 token을 직접 들고 서명을 검증받는 구조가 낫다.",
"consequences": [
"비교할 때 없앤 것과 새로 맡은 것, 잘 맞는 조건과 피해야 할 조건을 같이 적는다. 한쪽만 적으면 다시 성숙도 모델이 된다.",
"구조를 고를 때 번호가 아니라 code 교환·token 보관·API 호출의 배치를 먼저 답한다. 뒤 구조에서 앞 구조로 되돌아가는 선택도 후퇴가 아니라 credential 계약의 변경으로 적는다.",
"구조 이름만으로 운영 속성을 추정하지 않는다. 공유 저장소와 장애 복구, secret 교체는 매번 따로 확인한다."
]
}
@@ -0,0 +1,50 @@
---
id: 5f4b6000-cb78-400c-bf6e-a25632a4bb40
kind: PROJECT_DECISION
slug: patterns-are-not-a-maturity-ladder
title: 인증 구조를 보안 성숙도 단계로 취급하지 않는다
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 전
version: 11
decisionStatus: ADOPTED
decidedOn: 2026-08-24
studio: "https://hyeonworks.com/studio/documents/5f4b6000-cb78-400c-bf6e-a25632a4bb40/edit"
---
# 인증 구조를 보안 성숙도 단계로 취급하지 않는다
SPA, Mediator, BFF, Forward-Auth는 credential을 처리하는 주체와 API 호출 경로가 서로 다르다. 번호나 브라우저 token 노출 여부를 보안 등급으로 사용하지 않고 각각 별도의 아키텍처 패턴으로 취급한다.
## 근거
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
브라우저가 code 교환, token 보관, API 호출을 직접 수행한다.
- **Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조**
mediator가 code 교환과 refresh token 보관을 담당하고 브라우저가 access token으로 API를 직접 호출한다.
- **BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정**
BFF가 token과 session을 server-side에서 관리하고 Resource Server를 호출한다.
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
oauth2-proxy가 인증을 처리하고 upstream에는 identity header를 전달한다.
- **OAuth/OIDC 인증 패턴 선택 기준**
이 결정을 적용하는 선택 기준이다.
## 결정문
SPA에서 Mediator, BFF, OAuth2-Proxy로 가는 순서를 낮은 보안에서 높은 보안으로 가는 단계로 모델링하지 않는다.
네 구조는 credential과 인증 상태를 처리하는 주체가 서로 다른 별개의 아키텍처 패턴으로 취급한다.
## 판단 이유
BFF는 브라우저 token을 없애지만 server session과 CSRF, 공유 저장소를 만든다. Forward-Auth는 애플리케이션의 token custody를 줄이지만 edge 헤더 신뢰와 network 경계를 만든다. 뒤 구조가 앞 구조의 문제를 없애는 것이 아니라 다른 곳에 다른 요구를 만든다.
네 구조의 차이는 code를 교환하는 주체, token 저장 방식, API 호출 주체, 보호 자원이 신뢰하는 credential에서 확인됐다. 이 차이를 보안 성숙도 순서로 환산하지 않는다.
성숙도 모델로 두면 「일단 제일 뒤 구조로 가자」는 판단이 나온다. backend 직접 경로를 닫을 수 없는 환경에서 edge에 인증을 맡기면 upstream이 헤더 하나로 사용자를 판단하는데 그 헤더를 누구나 만들어 보낼 수 있다. 그런 환경에서는 브라우저가 token을 직접 들고 서명을 검증받는 구조가 낫다.
## 영향
- 패턴을 비교할 때는 적용 조건과 운영해야 할 상태, 신뢰 경계, 장애 지점을 함께 적는다. 브라우저 token 노출 여부 하나만으로 순서를 매기지 않는다.
- 구조를 고를 때 번호가 아니라 code 교환·token 보관·API 호출의 배치를 먼저 답한다. 뒤 구조에서 앞 구조로 되돌아가는 선택도 후퇴가 아니라 credential 계약의 변경으로 적는다.
- 구조 이름만으로 운영 속성을 추정하지 않는다. 공유 저장소와 장애 복구, secret 교체는 매번 따로 확인한다.
@@ -0,0 +1,51 @@
{
"kind": "QUESTION",
"title": "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가",
"slug": "bff-session-authorized-client-store",
"summary": "session과 authorized client는 찾는 열쇠가 달라서 같은 저장소에 두는 것이 당연하지 않다. 저장소 후보는 Redis 쪽으로 기울어 있지만 token 암호화와 만료 정합, logout 정리를 확인하지 않았다.",
"questionStatus": "OPEN",
"options": [
{
"title": "유력 후보 — session과 authorized client를 모두 Redis에 둔다",
"description": "Spring Session Redis와 Redis authorized-client repository를 쓰게 되면 만료를 store가 관리해 주고 인스턴스를 늘리기도 쉬워진다.\n\nRedis를 사용하면 모든 replica가 같은 session과 authorized client를 조회할 수 있다. 인증 경로가 Redis 가용성에 의존하게 되며, access·refresh token 저장 시 암호화 여부와 key 관리 방식도 정해야 한다."
},
{
"title": "session과 authorized client를 모두 JDBC에 둔다",
"description": "이미 운영 중인 DB를 쓴다. 백업과 감사 절차가 그 DB에 이미 있다면 그만큼 새로 만들 것이 줄어든다.\n\nJDBC를 사용하면 기존 관계형 DB 운영 체계를 활용할 수 있지만 인증 요청마다 DB 조회가 발생한다. 만료 데이터 정리와 session 조회 지연도 운영 항목으로 포함해야 한다."
},
{
"title": "변경이 가장 작은 안 — session만 공유하고 sticky session을 쓴다",
"description": "Spring Session만 붙이면 되어서 변경이 가장 적다.\n\nsticky session만 적용하면 authorized client는 여전히 process-local 상태다. 요청이 다른 인스턴스로 라우팅되거나 해당 인스턴스가 종료될 때 session과 token 상태의 정합을 보장하기 어렵다."
},
{
"title": "session은 Redis, token은 암호화한 JDBC에 둔다",
"description": "요청마다 읽는 session은 빠른 저장소에 두고 오래 보관하면서 암호화가 필요한 token은 DB에 두게 되어서 접근 패턴에 맞다.\n\nsession과 authorized client를 서로 다른 저장소에 두면 각각의 TTL과 logout 정리 순서를 맞춰야 하고 운영 대상 저장소도 하나 늘어난다."
}
],
"nextValidation": "후보마다 같은 입력으로 재서 비교한다.\n\n1. 인스턴스 두 대에서 로그인 유지와 재시작 복구가 되는지 본다.\n2. 저장소를 직접 열어 refresh token이 평문으로 남는지 확인한다.\n3. session TTL과 token 만료를 어긋나게 두고 그 순간의 응답과 화면을 기록한다.\n4. logout 뒤 두 store에 잔여 항목이 없는지 확인한다.\n5. 저장소를 끊은 상태에서 로그인과 API 호출이 어떤 오류를 내는지 본다.\n\n암호화 key 교체 절차는 후보를 고른 뒤에 따로 설계한다.",
"facts": [
"현재 구성에 Spring Session과 Redis, JDBC repository, 암호화 token store가 없다.",
"현재 HttpSession은 servlet container의 in-memory 구현을 사용하므로 해당 process가 종료되면 session 데이터도 유지되지 않는다.",
"OAuth2AuthorizedClientService도 자동구성이 고르는 in-memory 구현이고 코드가 직접 선언하지 않는다.",
"두 저장소의 열쇠가 달라서 session은 session ID로 찾고 authorized client는 registration 이름과 principal name으로 찾게 되고, 그래서 하나를 옮긴다고 다 하나가 따라오지 않는다.",
"authorized client manager에 authorization-code와 refresh-token provider가 함께 구성돼 있어서, 저장소를 공유하게 되면 여러 인스턴스가 같은 항목을 동시에 갱신할 수 있게 된다."
],
"assumptions": [
"두 상태를 같은 저장소에 둘 필요는 없다.",
"저장된 refresh token을 평문으로 두면 안 된다.",
"session 만료와 token 만료 중 하나가 먼저 오게 되면 그 순간의 동작이 정의돼 있어야 한다."
],
"unknowns": [
"Redis와 JDBC 중 무엇이 이 상태의 접근 패턴에 맞는가. 요청마다 읽는 값과 가끔 읽는 값이 섞여 있다.",
"session과 authorized client를 같은 store에 둘지 나눌지.",
"암호화 key를 어디에 두고 어떻게 교체하게 되는가. 교체하는 동안 이전 key로 저장된 값은 어떻게 읽는가.",
"session TTL과 refresh token 수명 중 어느 것을 기준으로 만료를 맞추게 되는가.",
"열쇠가 다른 두 store를 logout에서 어떻게 한 번에 지우게 되는가.",
"sticky session이 durable store의 대안이 되는가 보완이 되는가."
],
"constraints": [
"authorized client의 열쇠에 session ID가 없어서 session만 공유해도 같은 사용자의 여러 session이 같은 token 항목을 보게 된다.",
"커밋된 테스트에 저장소 관련 계약이 없어서 어느 후보를 골라도 지금은 회귀를 잡아 줄 검사가 없다.",
"모든 UI 요청이 BFF를 지나기 때문에 저장소 지연이 화면 지연으로 바로 드러나게 된다."
]
}
@@ -0,0 +1,94 @@
---
id: 18a5cde2-dd1e-4bff-9f1c-997577ae438f
kind: QUESTION
slug: bff-session-authorized-client-store
title: BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 전
version: 8
questionStatus: OPEN
studio: "https://hyeonworks.com/studio/documents/18a5cde2-dd1e-4bff-9f1c-997577ae438f/edit"
---
# BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가
session과 authorized client는 찾는 열쇠가 달라서 같은 저장소에 두는 것이 당연하지 않다. 저장소 후보는 Redis 쪽으로 기울어 있지만 token 암호화와 만료 정합, logout 정리를 확인하지 않았다.
## 관계
- **서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가**
이 질문에서 저장소 부분만 떼어 낸 것이다.
- **BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정**
session과 authorized client의 열쇠가 다르다는 사실의 출처다.
- **BFF 인증 구조 설계 기준**
이 기준의 저장소 항목이 이 질문의 답을 기다린다.
- **Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가**
저장소를 공유한 뒤에야 replica 경쟁이 재현된다.
## 사실
- 현재 구성에 Spring Session과 Redis, JDBC repository, 암호화 token store가 없다.
- 현재 HttpSession은 servlet container의 in-memory 구현을 사용하므로 해당 process가 종료되면 session 데이터도 유지되지 않는다.
- OAuth2AuthorizedClientService도 자동구성이 고르는 in-memory 구현이고 코드가 직접 선언하지 않는다.
- session은 session ID로 조회하고 authorized client는 registration 이름과 principal name으로 조회한다. 두 저장 구조를 shared store로 전환할 때 각각 따로 설계해야 한다.
- authorized client manager에 authorization-code와 refresh-token provider가 함께 구성돼 있어서, 저장소를 공유하게 되면 여러 인스턴스가 같은 항목을 동시에 갱신할 수 있게 된다.
## 가정
- 두 상태를 같은 저장소에 둘 필요는 없다.
- 저장된 refresh token을 평문으로 두면 안 된다.
- session 만료와 token 만료 중 하나가 먼저 오게 되면 그 순간의 동작이 정의돼 있어야 한다.
## 미지수
- Redis와 JDBC 중 무엇이 이 상태의 접근 패턴에 맞는가. 요청마다 읽는 값과 가끔 읽는 값이 섞여 있다.
- session과 authorized client를 같은 store에 둘지 나눌지.
- 암호화 key를 어디에 두고 어떻게 교체하게 되는가. 교체하는 동안 이전 key로 저장된 값은 어떻게 읽는가.
- session TTL과 refresh token 수명 중 어느 것을 기준으로 만료를 맞추게 되는가.
- 열쇠가 다른 두 store를 logout에서 어떻게 한 번에 지우게 되는가.
- sticky session이 durable store의 대안이 되는가 보완이 되는가.
## 제약
- authorized client의 열쇠에 session ID가 없어서 session만 공유해도 같은 사용자의 여러 session이 같은 token 항목을 보게 된다.
- 커밋된 테스트에 저장소 관련 계약이 없어서 어느 후보를 골라도 지금은 회귀를 잡아 줄 검사가 없다.
- 모든 UI 요청이 BFF를 지나기 때문에 저장소 지연이 화면 지연으로 바로 드러나게 된다.
## 선택지
### 1. 유력 후보 — session과 authorized client를 모두 Redis에 둔다
Spring Session Redis와 Redis authorized-client repository를 쓰게 되면 만료를 store가 관리해 주고 인스턴스를 늘리기도 쉬워진다.
Redis를 사용하면 모든 replica가 같은 session과 authorized client를 조회할 수 있다. 인증 경로가 Redis 가용성에 의존하게 되며, access·refresh token 저장 시 암호화 여부와 key 관리 방식도 정해야 한다.
### 2. session과 authorized client를 모두 JDBC에 둔다
이미 운영 중인 DB를 쓴다. 백업과 감사 절차가 그 DB에 이미 있다면 그만큼 새로 만들 것이 줄어든다.
JDBC를 사용하면 기존 관계형 DB 운영 체계를 활용할 수 있지만 인증 요청마다 DB 조회가 발생한다. 만료 데이터 정리와 session 조회 지연도 운영 항목으로 포함해야 한다.
### 3. 변경이 가장 작은 안 — session만 공유하고 sticky session을 쓴다
Spring Session만 붙이면 되어서 변경이 가장 적다.
sticky session만 적용하면 authorized client는 여전히 process-local 상태다. 요청이 다른 인스턴스로 라우팅되거나 해당 인스턴스가 종료될 때 session과 token 상태의 정합을 보장하기 어렵다.
### 4. session은 Redis, token은 암호화한 JDBC에 둔다
요청마다 읽는 session은 빠른 저장소에 두고 오래 보관하면서 암호화가 필요한 token은 DB에 두게 되어서 접근 패턴에 맞다.
session과 authorized client를 서로 다른 저장소에 두면 각각의 TTL과 logout 정리 순서를 맞춰야 하고 운영 대상 저장소도 하나 늘어난다.
## 다음 검증
후보마다 같은 입력으로 재서 비교한다.
1. 인스턴스 두 대에서 로그인 유지와 재시작 복구가 되는지 본다.
2. 저장소를 직접 열어 refresh token이 평문으로 남는지 확인한다.
3. session TTL과 token 만료를 어긋나게 두고 그 순간의 응답과 화면을 기록한다.
4. logout 뒤 두 store에 잔여 항목이 없는지 확인한다.
5. 저장소를 끊은 상태에서 로그인과 API 호출이 어떤 오류를 내는지 본다.
암호화 key 교체 절차는 후보를 고른 뒤에 따로 설계한다.
@@ -0,0 +1,48 @@
{
"kind": "QUESTION",
"title": "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가",
"slug": "edge-authorization-scope",
"summary": "지금 edge는 user와 email만 전달하고 upstream은 role 판단을 하지 않는다. 다음 요구가 들어왔을 때 role까지 헤더로 보낼지, 아니면 인가를 애플리케이션으로 되돌릴지 정하지 않았다.",
"questionStatus": "OPEN",
"options": [
{
"title": "현재 — 인증만 edge에 둔다",
"description": "헤더가 user와 email 둘로 고정돼 있어서 계약이 가장 작고 크기 상한 문제도 생기지 않는다. 인가는 upstream이 자기 저장소로 해결한다. 서비스마다 권한 조회를 따로 붙여야 한다."
},
{
"title": "다음 후보 — role 전달까지 edge에 둔다",
"description": "공통 role을 한 곳에서 주면 서비스마다 권한을 조회하지 않아도 된다. 이 선택을 하면 다중 값 직렬화와 크기 상한, 갱신 시점 계약을 먼저 정해야 한다. upstream은 그 값을 검증할 수단이 없어서 edge가 틀리면 그대로 틀린다."
},
{
"title": "보류 — tenant와 인가 판단까지 edge에 둔다",
"description": "tenant는 잘못 들어간 값 하나가 다른 조직의 데이터를 그대로 열어 준다. 이 값만은 upstream이 다시 확인할 수단을 함께 설계해야 해서 지금 구성으로는 감당할 수 없다. 인가 판단까지 옮기면 edge가 애플리케이션 도메인을 알아야 하고 정책이 바뀔 때마다 edge를 배포하게 된다."
},
{
"title": "경계가 커지면 — BFF로 되돌린다",
"description": "role·tenant 정보를 edge header로 계속 확장하지 않고 BFF가 필요한 정보를 조회해 인가와 API 조합을 처리하는 선택지도 있다. 이 경우 BFF session, CSRF 검증, shared store 운영이 다시 필요하다."
}
],
"nextValidation": "upstream이 실제로 요구하는 claim을 먼저 적는다. 그 목록을 놓고 아래를 본다.\n\n1. 전달하려는 claim이 계속 늘어나는가.\n2. role이나 tenant 변경이 즉시 반영돼야 하는가.\n3. 정책이 애플리케이션 도메인을 알아야 하는가.\n4. 헤더 값이 인가 판단의 근거가 되는가.\n5. 서비스별 정책 차이가 커지는가.\n\n2번부터 5번 중 하나라도 그렇다면 헤더를 늘리는 방향이 아니라 되돌리는 방향을 본다.\n\nrole을 헤더로 실은 구성을 먼저 만들어 다중 값과 크기 상한을 넣고 무엇이 먼저 깨지는지 확인한다. role을 바꾼 뒤 몇 번째 요청부터 반영되는지도 잰다.",
"facts": [
"지금 edge 응답은 user와 email만 전달한다. role과 groups, tenant, 인증 방식, token 만료는 전달하지 않는다.",
"upstream의 identity endpoint는 role 판단을 하지 않고 누가 왔는지만 응답에 담는다.",
"internal token 검사가 controller 한 곳에 있고 security 설정은 그 경로 전체를 permitAll로 둔다. 새 endpoint에는 보호가 따라오지 않는다.",
"Nginx는 client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다. 늘리는 헤더도 같은 처리를 받아야 한다.",
"upstream은 JWT를 입력으로 받지 않아서 헤더로 온 값을 스스로 검증할 수단이 없다."
],
"assumptions": [
"헤더 종류가 늘어나면 정해야 할 계약도 함께 늘어난다.",
"role이 바뀌는 시점과 요청이 오는 시점이 달라서 그 사이에 들어온 요청은 옛 값을 본다."
],
"unknowns": [
"다중 값 role을 어떤 구분자와 escaping으로 보낼지. 값 안에 그 구분자가 들어오면 어떻게 되는지.",
"헤더 크기 상한을 넘으면 무엇이 먼저 깨지는지. proxy가 자르는지 요청 자체가 거부되는지.",
"role이 바뀌었을 때 proxy session과 downstream 인가가 언제 따라가는지. 권한 회수가 몇 분 뒤에 반영되는지.",
"upstream이 헤더 존재만 볼지 값과 service identity까지 볼지."
],
"constraints": [
"전달할 헤더는 allowlist로 고정해야 하고 client가 보낸 동명 헤더는 언제나 덮어써야 한다.",
"internal token 검사가 controller 한 곳에만 있다. 헤더를 늘리기 전에 이 검사를 공통 경계로 옮기는 것이 먼저다.",
"upstream을 고칠 수 없어서 이 구조를 골랐다면 BFF로 되돌리는 선택지는 없다."
]
}
@@ -0,0 +1,83 @@
---
id: 7ff40767-a00b-4db2-98f6-0cdfce8c8936
kind: QUESTION
slug: edge-authorization-scope
title: Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 전
version: 9
questionStatus: OPEN
studio: "https://hyeonworks.com/studio/documents/7ff40767-a00b-4db2-98f6-0cdfce8c8936/edit"
---
# Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가
지금 edge는 user와 email만 전달하고 upstream은 role 판단을 하지 않는다. 다음 요구가 들어왔을 때 role까지 헤더로 보낼지, 아니면 인가를 애플리케이션으로 되돌릴지 정하지 않았다.
## 관계
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
edge가 user와 email만 전달한다는 사실의 출처다.
- **Forward-Auth에서 Identity Header를 신뢰하기 위한 조건**
헤더 allowlist와 검증 조건이 이 기준에 있다.
- **BFF 인증 구조 설계 기준**
되돌리는 선택지의 기준이 이 문서다.
## 사실
- 지금 edge 응답은 user와 email만 전달한다. role과 groups, tenant, 인증 방식, token 만료는 전달하지 않는다.
- upstream의 identity endpoint는 role 판단을 하지 않고 누가 왔는지만 응답에 담는다.
- internal token 검사가 controller 한 곳에 있고 security 설정은 그 경로 전체를 permitAll로 둔다. 새 endpoint에는 보호가 따라오지 않는다.
- Nginx는 client가 보낸 동명 헤더를 merge하지 않고 덮어쓴다. 늘리는 헤더도 같은 처리를 받아야 한다.
- upstream은 JWT를 입력으로 받지 않아서 헤더로 온 값을 스스로 검증할 수단이 없다.
## 가정
- 헤더 종류가 늘어나면 정해야 할 계약도 함께 늘어난다.
- role이 바뀌는 시점과 요청이 오는 시점이 달라서 그 사이에 들어온 요청은 옛 값을 본다.
## 미지수
- 다중 값 role을 어떤 구분자와 escaping으로 보낼지. 값 안에 그 구분자가 들어오면 어떻게 되는지.
- 헤더 크기 상한을 넘으면 무엇이 먼저 깨지는지. proxy가 자르는지 요청 자체가 거부되는지.
- role이 바뀌었을 때 proxy session과 downstream 인가가 언제 따라가는지. 권한 회수가 몇 분 뒤에 반영되는지.
- upstream이 헤더 존재만 볼지 값과 service identity까지 볼지.
## 제약
- 전달할 헤더는 allowlist로 고정해야 하고 client가 보낸 동명 헤더는 언제나 덮어써야 한다.
- internal token 검사가 controller 한 곳에만 있다. 헤더를 늘리기 전에 이 검사를 공통 경계로 옮기는 것이 먼저다.
- upstream을 고칠 수 없어서 이 구조를 골랐다면 BFF로 되돌리는 선택지는 없다.
## 선택지
### 1. 현재 — 인증만 edge에 둔다
헤더가 user와 email 둘로 고정돼 있어서 계약이 가장 작고 크기 상한 문제도 생기지 않는다. 인가는 upstream이 자기 저장소로 해결한다. 서비스마다 권한 조회를 따로 붙여야 한다.
### 2. 다음 후보 — role 전달까지 edge에 둔다
공통 role을 한 곳에서 주면 서비스마다 권한을 조회하지 않아도 된다. 이 선택을 하면 다중 값 직렬화와 크기 상한, 갱신 시점 계약을 먼저 정해야 한다. upstream은 그 값을 검증할 수단이 없어서 edge가 틀리면 그대로 틀린다.
### 3. 보류 — tenant와 인가 판단까지 edge에 둔다
tenant는 잘못 들어간 값 하나가 다른 조직의 데이터를 그대로 열어 준다. 이 값만은 upstream이 다시 확인할 수단을 함께 설계해야 해서 지금 구성으로는 감당할 수 없다. 인가 판단까지 옮기면 edge가 애플리케이션 도메인을 알아야 하고 정책이 바뀔 때마다 edge를 배포하게 된다.
### 4. 경계가 커지면 — BFF로 되돌린다
role·tenant 정보를 edge header로 계속 확장하지 않고 BFF가 필요한 정보를 조회해 인가와 API 조합을 처리하는 선택지도 있다. 이 경우 BFF session, CSRF 검증, shared store 운영이 다시 필요하다.
## 다음 검증
upstream이 실제로 요구하는 claim을 먼저 적는다. 그 목록을 놓고 아래를 본다.
1. 전달하려는 claim이 계속 늘어나는가.
2. role이나 tenant 변경이 즉시 반영돼야 하는가.
3. 정책이 애플리케이션 도메인을 알아야 하는가.
4. 헤더 값이 인가 판단의 근거가 되는가.
5. 서비스별 정책 차이가 커지는가.
2번부터 5번 중 하나라도 그렇다면 헤더를 늘리는 방향이 아니라 되돌리는 방향을 본다.
role을 헤더로 실은 구성을 먼저 만들어 다중 값과 크기 상한을 넣고 무엇이 먼저 깨지는지 확인한다. role을 바꾼 뒤 몇 번째 요청부터 반영되는지도 잰다.
@@ -0,0 +1,53 @@
{
"kind": "QUESTION",
"title": "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가",
"slug": "server-session-pattern-multi-instance",
"summary": "Mediator와 BFF는 로그인 상태와 token 상태를 열쇠가 다른 두 저장소에 나눠 두게 되는데 지금은 두 상태가 다 process 안에 있다. 인스턴스가 둘 이상인 운영에서 재시작과 이동, logout이 어떻게 동작해야 하는지 아직 정하지 않았다.",
"questionStatus": "OPEN",
"options": [
{
"title": "공유 durable store로 옮긴다",
"description": "HttpSession과 authorized client를 모두 외부 store에 두게 되면 인스턴스가 늘어도 같은 상태를 찾고 재시작도 견디게 된다.\n\n공유 저장소를 사용하면 replica가 같은 상태를 조회할 수 있다. 반면 인증 경로가 저장소 가용성에 의존하므로 장애 처리, 직렬화 형식, token 암호화, session과 token의 만료 정합을 함께 설계해야 한다."
},
{
"title": "session affinity로 묶는다",
"description": "같은 사용자를 같은 인스턴스로 보내게 되어서 코드를 거의 안 고쳐도 되고 저장소도 늘지 않는다.\n\nsticky session은 평상시 요청을 같은 인스턴스로 보낼 수 있지만 해당 인스턴스가 종료되면 process-local 상태도 함께 사용할 수 없게 된다. 배포나 오토스케일링처럼 인스턴스 교체가 잦은 환경에서는 별도 복구 전략이 필요하다."
},
{
"title": "브라우저가 token을 들고 API를 직접 부르게 되돌린다",
"description": "server에 상태를 두지 않게 되어서 공유 저장소도 affinity도 필요 없어지고 Resource Server는 요청마다 서명만 검증한다.\n\nSPA처럼 browser token을 사용하는 구조로 바꾸는 방법도 있지만, 브라우저에 OAuth token을 전달하지 않는 정책이 있다면 후보에서 제외한다."
},
{
"title": "저장소 선택이 아니라 구조 변경 — 최소 정보만 담은 client-side cookie",
"description": "이것은 저장소를 바꾸는 선택이 아니다. server-side store를 없애고 인증 상태를 cookie 자체에 담는 구조 변경이라서 앞의 세 후보와 같은 층에 놓고 비교할 수 없다.\n\nForward-Auth로 전환하면 애플리케이션이 server-side OAuth token store를 운영하지 않아도 된다. 이 구조에서는 replica가 공유할 cookie secret과 edge identity header를 신뢰하기 위한 network·header 검증을 운영해야 한다."
}
],
"nextValidation": "인스턴스를 둘로 띄우고 순서대로 확인한다.\n\n1. 한쪽에서 로그인한 뒤 다른 인스턴스로 요청을 보내 200이 유지되는지 본다.\n2. 한 인스턴스를 재시작하고 같은 session cookie로 로그인 상태가 남는지 본다.\n3. 같은 사용자로 두 브라우저에서 로그인해 authorized client 항목이 서로를 덮어쓰는지 본다.\n4. 한쪽에서 logout한 뒤 다른 쪽 요청이 어떻게 되는지 본다.\n5. session 만료를 token 만료보다 짧게, 다시 길게 두고 각 경우의 응답과 화면을 기록한다.\n\n여기서 무엇이 깨지는지가 갈리게 되면 저장소 후보 비교로 넘어간다.",
"facts": [
"Mediator와 BFF는 로그인 상태를 HttpSession에 두고 token은 OAuth2AuthorizedClientService에 두게 되는데, 두 저장소는 열쇠가 다르다. session은 session ID로 찾고 authorized client는 registration 이름과 principal name으로 찾는다.",
"현재 두 저장소는 Spring Boot 자동구성이 선택한 in-memory 구현을 사용한다. 코드에서 store bean을 직접 선언하지 않았기 때문에 실제 구현은 자동구성 결과를 함께 확인해야 한다.",
"Spring Session과 Redis, JDBC token store 의존성이 없어서 두 상태가 모두 process 안에 있다. 그 instance가 종료되면 그 instance가 들고 있던 session과 authorized client는 사라진다.",
"authorized client의 열쇠에 session ID가 없기 때문에 같은 사용자가 두 브라우저에서 로그인하면 같은 항목을 보게 된다.",
"OAuth2-Proxy 구조는 server-side session store를 두지 않고 최소 정보만 담은 client-side cookie를 쓰게 되며, cookie 만료는 proxy 설정의 1 hour다.",
"커밋된 테스트에 재시작이나 replica 이동 뒤 복구 계약이 없어서 지금 무엇을 바꿔도 회귀를 잡아 줄 검사가 없다."
],
"assumptions": [
"운영에서는 인스턴스가 둘 이상이다.",
"재시작과 배포가 로그인 상태를 끊어서는 안 되는데, 지금 구조에서는 끊기게 된다.",
"같은 사용자의 여러 브라우저 session이 서로의 token 항목을 덮어써서는 안 된다."
],
"unknowns": [
"재시작 뒤 로그인이 유지되는가. 지금은 안 된다는 것까지 알지만 무엇을 바꿔야 되는지는 정하지 않았다.",
"인스턴스가 바뀌어도 같은 session을 찾게 되는가.",
"같은 사용자의 여러 session이 authorized client 항목을 공유하거나 덮어쓰게 되는가. 한쪽에서 로그아웃하면 다른 쪽도 끊기게 되는가.",
"저장된 refresh token이 암호화되는가. 저장소를 여는 사람이 그 값을 그대로 읽게 되는가.",
"logout이 열쇠가 다른 두 상태를 함께 지우게 되는가. 한쪽만 지우면 다음 로그인에서 남은 쪽으로 복구되는가.",
"session 만료와 token 만료가 어긋나면 무엇이 먼저 실패하고 사용자 화면에는 어떻게 보이게 되는가.",
"OAuth2-Proxy 구조의 replica들이 같은 cookie secret을 어떻게 공유하고 교체하게 되는가. 교체하는 동안 로그인해 있던 사람은 어떻게 되는가."
],
"constraints": [
"현재 예제는 단일 인스턴스로 실행하고 있어 replica 간 session 조회와 failover 동작은 아직 재현하지 않았다.",
"authorized client의 key에는 session ID가 없다. session store를 shared store로 바꾸는 작업과 authorized client 저장 방식을 정하는 작업은 별도로 필요하다.",
"Resource Server의 8081이 host에도 열려 있어서 모든 client가 BFF만 거치도록 network에서 강제된 상태가 아니다."
]
}
@@ -0,0 +1,96 @@
---
id: c72656b5-842d-45d9-b5f6-82b66b09d0b9
kind: QUESTION
slug: server-session-pattern-multi-instance
title: 서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 전
version: 10
questionStatus: OPEN
studio: "https://hyeonworks.com/studio/documents/c72656b5-842d-45d9-b5f6-82b66b09d0b9/edit"
---
# 서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가
Mediator와 BFF는 로그인 상태와 token 상태를 열쇠가 다른 두 저장소에 나눠 두게 되는데 지금은 두 상태가 다 process 안에 있다. 인스턴스가 둘 이상인 운영에서 재시작과 이동, logout이 어떻게 동작해야 하는지 아직 정하지 않았다.
## 관계
- **BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정**
두 상태가 모두 process-local memory에 있다는 사실의 출처다.
- **Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조**
같은 저장소 구성을 쓰는 다른 패턴이다.
- **BFF 인증 구조 설계 기준**
이 질문의 답이 이 기준의 빈 항목을 채운다.
- **BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가**
저장소 후보 비교로 독립시킨 질문이다.
## 사실
- Mediator와 BFF는 로그인 상태를 HttpSession에 두고 token은 OAuth2AuthorizedClientService에 두게 되는데, 두 저장소는 열쇠가 다르다. session은 session ID로 찾고 authorized client는 registration 이름과 principal name으로 찾는다.
- 현재 두 저장소는 Spring Boot 자동구성이 선택한 in-memory 구현을 사용한다. 코드에서 store bean을 직접 선언하지 않았기 때문에 실제 구현은 자동구성 결과를 함께 확인해야 한다.
- Spring Session과 Redis, JDBC token store 의존성이 없어서 두 상태가 모두 process 안에 있다. 그 instance가 종료되면 그 instance가 들고 있던 session과 authorized client는 사라진다.
- authorized client의 열쇠에 session ID가 없기 때문에 같은 사용자가 두 브라우저에서 로그인하면 같은 항목을 보게 된다.
- OAuth2-Proxy 구조는 server-side session store를 두지 않고 최소 정보만 담은 client-side cookie를 쓰게 되며, cookie 만료는 proxy 설정의 1 hour다.
- 커밋된 테스트에 재시작이나 replica 이동 뒤 복구 계약이 없어서 지금 무엇을 바꿔도 회귀를 잡아 줄 검사가 없다.
## 가정
- 운영에서는 인스턴스가 둘 이상이다.
- 재시작과 배포가 로그인 상태를 끊어서는 안 되는데, 지금 구조에서는 끊기게 된다.
- 같은 사용자의 여러 브라우저 session이 서로의 token 항목을 덮어써서는 안 된다.
## 미지수
- 재시작 뒤 로그인이 유지되는가. 지금은 안 된다는 것까지 알지만 무엇을 바꿔야 되는지는 정하지 않았다.
- 인스턴스가 바뀌어도 같은 session을 찾게 되는가.
- 같은 사용자의 여러 session이 authorized client 항목을 공유하거나 덮어쓰게 되는가. 한쪽에서 로그아웃하면 다른 쪽도 끊기게 되는가.
- 저장된 refresh token이 암호화되는가. 저장소를 여는 사람이 그 값을 그대로 읽게 되는가.
- logout에서 HttpSession과 authorized client를 모두 정리하는가. 한쪽만 삭제했을 때 다음 요청이나 재로그인에서 어떤 상태가 복원되는가.
- session 만료와 token 만료가 어긋나면 무엇이 먼저 실패하고 사용자 화면에는 어떻게 보이게 되는가.
- OAuth2-Proxy 구조의 replica들이 같은 cookie secret을 어떻게 공유하고 교체하게 되는가. 교체하는 동안 로그인해 있던 사람은 어떻게 되는가.
## 제약
- 현재 예제는 단일 인스턴스로 실행하고 있어 replica 간 session 조회와 failover 동작은 아직 재현하지 않았다.
- authorized client의 key에는 session ID가 없다. session store를 shared store로 바꾸는 작업과 authorized client 저장 방식을 정하는 작업은 별도로 필요하다.
- Resource Server의 8081이 host에도 열려 있어서 모든 client가 BFF만 거치도록 network에서 강제된 상태가 아니다.
## 선택지
### 1. 공유 저장소를 사용한다
HttpSession과 authorized client를 모두 외부 store에 두게 되면 인스턴스가 늘어도 같은 상태를 찾고 재시작도 견디게 된다.
공유 저장소를 사용하면 replica가 같은 상태를 조회할 수 있다. 반면 인증 경로가 저장소 가용성에 의존하므로 장애 처리, 직렬화 형식, token 암호화, session과 token의 만료 정합을 함께 설계해야 한다.
### 2. session affinity로 묶는다
같은 사용자를 같은 인스턴스로 보내게 되어서 코드를 거의 안 고쳐도 되고 저장소도 늘지 않는다.
sticky session은 평상시 요청을 같은 인스턴스로 보낼 수 있지만 해당 인스턴스가 종료되면 process-local 상태도 함께 사용할 수 없게 된다. 배포나 오토스케일링처럼 인스턴스 교체가 잦은 환경에서는 별도 복구 전략이 필요하다.
### 3. 브라우저가 token을 들고 API를 직접 부르게 되돌린다
server에 상태를 두지 않게 되어서 공유 저장소도 affinity도 필요 없어지고 Resource Server는 요청마다 서명만 검증한다.
SPA처럼 browser token을 사용하는 구조로 바꾸는 방법도 있지만, 브라우저에 OAuth token을 전달하지 않는 정책이 있다면 후보에서 제외한다.
### 4. 저장소 선택이 아니라 구조 변경 — 최소 정보만 담은 client-side cookie
이것은 저장소를 바꾸는 선택이 아니다. server-side store를 없애고 인증 상태를 cookie 자체에 담는 구조 변경이라서 앞의 세 후보와 같은 층에 놓고 비교할 수 없다.
Forward-Auth로 전환하면 애플리케이션이 server-side OAuth token store를 운영하지 않아도 된다. 이 구조에서는 replica가 공유할 cookie secret과 edge identity header를 신뢰하기 위한 network·header 검증을 운영해야 한다.
## 다음 검증
인스턴스를 둘로 띄우고 순서대로 확인한다.
1. 한쪽에서 로그인한 뒤 다른 인스턴스로 요청을 보내 200이 유지되는지 본다.
2. 한 인스턴스를 재시작하고 같은 session cookie로 로그인 상태가 남는지 본다.
3. 같은 사용자로 두 브라우저에서 로그인해 authorized client 항목이 서로를 덮어쓰는지 본다.
4. 한쪽에서 logout한 뒤 다른 쪽 요청이 어떻게 되는지 본다.
5. session 만료를 token 만료보다 짧게, 다시 길게 두고 각 경우의 응답과 화면을 기록한다.
여기서 무엇이 깨지는지가 갈리게 되면 저장소 후보 비교로 넘어간다.
@@ -0,0 +1,51 @@
{
"kind": "QUESTION",
"title": "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가",
"slug": "refresh-rotation-replica-contention",
"summary": "realm이 refresh token rotation과 재사용 허용 0회를 쓴다. 두 replica가 같은 refresh token으로 동시에 갱신할 수 있고, 그때 두 번째 사용이 거부될 가능성이 있다. 실제 Keycloak 응답과 session 영향은 아직 재현하지 않았다.",
"questionStatus": "OPEN",
"options": [
{
"title": "분산 lock으로 갱신을 직렬화한다",
"description": "한 replica만 갱신하고 나머지는 끝나기를 기다렸다가 결과를 읽게 되어서 재사용 거부가 아예 생기지 않는다.\n\n분산 lock을 사용하면 refresh 구간을 직렬화할 수 있다. lock 저장소의 가용성, lock 만료, 재진입, lock 보유 process 종료 상황까지 함께 처리해야 한다."
},
{
"title": "각자 갱신하고 실패는 재시도로 처리한다",
"description": "구현이 가장 단순하다. 지는 쪽이 거부를 받으면 저장소에서 최신 token을 다시 읽어 재시도한다는 전제인데, 이 재시도가 성립하는지는 아직 확인하지 않았다.\n\nreuse detection 정책에 따라 같은 refresh token의 두 번째 사용이 token family 전체에 영향을 줄 수 있다. 이 경우 단순 retry로 끝나지 않고 재인증이 필요할 수 있다. 갱신에 성공한 replica가 새 token을 저장하기 전에 다른 replica가 다시 조회하는 순서도 별도로 재현해야 한다."
},
{
"title": "갱신 전용 경로를 하나 둔다",
"description": "refresh를 전담하는 구성요소 하나만 refresh token을 사용하고 다른 replica는 갱신 결과를 조회하도록 구성할 수 있다.\n\nrefresh 전담 구성요소가 중단되면 access token 만료 이후 갱신을 수행할 주체가 없어지므로 해당 구성요소의 가용성과 복구 방식이 중요해진다."
},
{
"title": "제약상 제외 — 재사용 허용을 늘린다",
"description": "짧은 유예를 주면 경쟁이 저절로 해소되고 코드도 고칠 필요가 없다. 다만 rotation과 재사용 0회는 이 질문이 바꾸지 않기로 한 realm 설정이다. 훔친 refresh token을 쓸 수 있는 창도 같이 늘어난다.\n\n비교 대상으로만 남긴다."
}
],
"nextValidation": "저장소를 공유한 뒤에 재현한다.\n\n1. replica 두 대에서 같은 사용자로 access token 만료 직후 동시에 요청을 보낸다.\n2. 이긴 쪽과 지는 쪽의 응답을 각각 기록한다.\n3. 지는 쪽이 저장된 새 token으로 재시도해 성공하는지 본다.\n4. 지는 쪽 사용자 화면에 무엇이 보이는지 기록한다.\n5. lock을 넣은 구성과 안 넣은 구성을 같은 입력으로 비교해 실패율과 지연을 잰다.\n\n실패가 사용자에게 노출되면 lock을 고르고, 노출되지 않으면 재시도로 둔다.",
"facts": [
"realm은 refresh token rotation과 재사용 허용 0회를 쓰게 되어서, 한 번 갱신하면 이전 refresh token은 바로 무효가 된다.",
"커밋된 테스트는 새 refresh token 발급과 이전 token 거부, revocation 뒤 refresh 실패를 확인하는데 모두 한 주체가 순서대로 부르는 경우다.",
"authorized client manager에는 refresh-token provider가 구성되어 있어 access token 만료 시 refresh를 시도할 수 있다.",
"다만 만료를 기다려 실제 갱신이 성공하고 새 token이 저장되는지까지는 확인하지 않았다.",
"현재 authorized client 저장소는 process-local이라 replica가 같은 refresh token 상태를 공유하지 않는다. 따라서 이번 단일 인스턴스 검증에서는 동시 refresh 경쟁을 재현하지 않았다.",
"이미 발급된 access token은 만료 전까지 API에서 계속 통하기 때문에, 갱신이 실패해도 그동안은 화면이 정상으로 보이게 된다."
],
"assumptions": [
"운영에서는 replica가 둘 이상이고 저장소를 공유해 같은 authorized client 항목을 보게 된다.",
"두 replica가 비슷한 시각에 만료를 만나면 각각 갱신을 시도하게 된다."
],
"unknowns": [
"같은 refresh token으로 두 replica가 동시에 갱신하면 어느 쪽이 이기고 지는 쪽은 무엇을 받게 되는가.",
"재사용 허용 0회에서 지는 쪽의 요청이 사용자 화면에 어떻게 보이게 되는가. 로그인 만료로 보이는가 일시적 오류로 보이는가.",
"지는 쪽이 저장소에서 새 token을 다시 읽어 재시도하면 성공하게 되는가, 아니면 재인증이 필요해지는가.",
"갱신을 한 곳에서만 할 것인가, 각자 하게 두고 실패는 재시도로 처리할 것인가.",
"lock을 쓴다면 어디에 두고 얼마나 잡게 되는가. 잡은 채로 프로세스가 내려가면 어떻게 푸는가.",
"갱신 실패를 로그인 만료와 구분해서 표시할 수 있게 되는가."
],
"constraints": [
"rotation과 재사용 0회는 이미 realm 설정이라서 이 전제를 바꾸지 않고 답해야 한다.",
"이미 발급된 access token은 만료 전까지 사용할 수 있으므로 refresh 실패는 즉시 보이지 않을 수 있다. 재현 테스트는 access token 만료 직후에 맞춰 실행한다.",
"이 경쟁은 저장소를 공유한 뒤에야 재현되기 때문에 저장소 결정이 이 질문보다 앞서게 된다."
]
}
@@ -0,0 +1,94 @@
---
id: 9ae4ec71-a32e-49a7-88c2-f7368541c28d
kind: QUESTION
slug: refresh-rotation-replica-contention
title: Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 전
version: 10
questionStatus: OPEN
studio: "https://hyeonworks.com/studio/documents/9ae4ec71-a32e-49a7-88c2-f7368541c28d/edit"
---
# Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가
realm이 refresh token rotation과 재사용 허용 0회를 쓴다. 두 replica가 같은 refresh token으로 동시에 갱신할 수 있고, 그때 두 번째 사용이 거부될 가능성이 있다. 실제 Keycloak 응답과 session 영향은 아직 재현하지 않았다.
## 관계
- **BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가**
저장소 결정이 이 질문보다 앞선다.
- **Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조**
rotation과 재사용 0회를 쓰는 구성의 출처다.
- **서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가**
다중 인스턴스 운영이 이 경쟁의 전제다.
- **BFF 인증 구조 설계 기준**
갱신 실패를 화면 오류로 바꾸는 규칙이 이 기준의 항목이다.
## 사실
- realm은 refresh token rotation과 재사용 허용 0회를 쓰게 되어서, 한 번 갱신하면 이전 refresh token은 바로 무효가 된다.
- 커밋된 테스트는 새 refresh token 발급과 이전 token 거부, revocation 뒤 refresh 실패를 확인하는데 모두 한 주체가 순서대로 부르는 경우다.
- authorized client manager에는 refresh-token provider가 구성되어 있어 access token 만료 시 refresh를 시도할 수 있다.
- 다만 만료를 기다려 실제 갱신이 성공하고 새 token이 저장되는지까지는 확인하지 않았다.
- 현재 authorized client 저장소는 process-local이라 replica가 같은 refresh token 상태를 공유하지 않는다. 따라서 이번 단일 인스턴스 검증에서는 동시 refresh 경쟁을 재현하지 않았다.
- 이미 발급된 access token은 만료 전까지 API에서 계속 통하기 때문에, 갱신이 실패해도 그동안은 화면이 정상으로 보이게 된다.
## 가정
- 운영에서는 replica가 둘 이상이고 저장소를 공유해 같은 authorized client 항목을 보게 된다.
- 두 replica가 비슷한 시각에 만료를 만나면 각각 갱신을 시도하게 된다.
## 미지수
- 같은 refresh token으로 두 replica가 동시에 갱신하면 어느 쪽이 이기고 지는 쪽은 무엇을 받게 되는가.
- 재사용 허용 0회에서 지는 쪽의 요청이 사용자 화면에 어떻게 보이게 되는가. 로그인 만료로 보이는가 일시적 오류로 보이는가.
- 지는 쪽이 저장소에서 새 token을 다시 읽어 재시도하면 성공하게 되는가, 아니면 재인증이 필요해지는가.
- 갱신을 한 곳에서만 할 것인가, 각자 하게 두고 실패는 재시도로 처리할 것인가.
- lock을 쓴다면 어디에 두고 얼마나 잡게 되는가. 잡은 채로 프로세스가 내려가면 어떻게 푸는가.
- 갱신 실패를 로그인 만료와 구분해서 표시할 수 있게 되는가.
## 제약
- rotation과 재사용 0회는 이미 realm 설정이라서 이 전제를 바꾸지 않고 답해야 한다.
- 이미 발급된 access token은 만료 전까지 사용할 수 있으므로 refresh 실패는 즉시 보이지 않을 수 있다. 재현 테스트는 access token 만료 직후에 맞춰 실행한다.
- 이 경쟁은 저장소를 공유한 뒤에야 재현되기 때문에 저장소 결정이 이 질문보다 앞서게 된다.
## 선택지
### 1. 분산 lock으로 갱신을 직렬화한다
한 replica만 갱신하고 나머지는 끝나기를 기다렸다가 결과를 읽게 되어서 재사용 거부가 아예 생기지 않는다.
분산 lock을 사용하면 refresh 구간을 직렬화할 수 있다. lock 저장소의 가용성, lock 만료, 재진입, lock 보유 process 종료 상황까지 함께 처리해야 한다.
### 2. 각자 갱신하고 실패는 재시도로 처리한다
구현이 가장 단순하다. 지는 쪽이 거부를 받으면 저장소에서 최신 token을 다시 읽어 재시도한다는 전제인데, 이 재시도가 성립하는지는 아직 확인하지 않았다.
reuse detection 정책에 따라 같은 refresh token의 두 번째 사용이 token family 전체에 영향을 줄 수 있다. 이 경우 단순 retry로 끝나지 않고 재인증이 필요할 수 있다. 갱신에 성공한 replica가 새 token을 저장하기 전에 다른 replica가 다시 조회하는 순서도 별도로 재현해야 한다.
### 3. 갱신 전용 경로를 하나 둔다
refresh를 전담하는 구성요소 하나만 refresh token을 사용하고 다른 replica는 갱신 결과를 조회하도록 구성할 수 있다.
refresh 전담 구성요소가 중단되면 access token 만료 이후 갱신을 수행할 주체가 없어지므로 해당 구성요소의 가용성과 복구 방식이 중요해진다.
### 4. 제약상 제외 — 재사용 허용을 늘린다
짧은 유예를 주면 경쟁이 저절로 해소되고 코드도 고칠 필요가 없다. 다만 rotation과 재사용 0회는 이 질문이 바꾸지 않기로 한 realm 설정이다. 훔친 refresh token을 쓸 수 있는 창도 같이 늘어난다.
비교 대상으로만 남긴다.
## 다음 검증
저장소를 공유한 뒤에 재현한다.
1. replica 두 대에서 같은 사용자로 access token 만료 직후 동시에 요청을 보낸다.
2. 이긴 쪽과 지는 쪽의 응답을 각각 기록한다.
3. 지는 쪽이 저장된 새 token으로 재시도해 성공하는지 본다.
4. 지는 쪽 사용자 화면에 무엇이 보이는지 기록한다.
5. lock을 넣은 구성과 안 넣은 구성을 같은 입력으로 비교해 실패율과 지연을 잰다.
실패가 사용자에게 노출되면 lock을 고르고, 노출되지 않으면 재시도로 둔다.
@@ -0,0 +1,55 @@
{
"kind": "REFERENCE",
"title": "Authorization Code Flow의 Endpoint와 Credential 이동 기준",
"slug": "authorization-code-endpoint-credential-movement",
"summary": "Authorization Code Flow에서 브라우저와 client, Authorization Server, Resource Server가 주고받는 값을 endpoint별로 정리한다. 특히 `client_secret`과 authorization code, access token이 어느 요청에 포함되는지를 구분한다.",
"purpose": "Authorization Endpoint와 Token Endpoint는 역할과 호출 방식이 다르다.\n이 구분을 해야 SPA에서 client_secret이 어디로 갔는지, PKCE가 어느 구간을 지키는지 이해하기 쉽다.\n\n하나는 브라우저의 full-page navigation이고 하나는 server-to-server 호출이 될 수도 있고 browser-to-server 호출이 될 수도 있다.\n노출되는 것도, 인증하는 방법도 다르다.\n\nAuthorization Endpoint\n경로 : 브라우저 주소창 남는 곳 : 히스토리·서버 로그·referrer client 인증 : x\n\nToken Endpoint\n경로 : body와 Authorization 헤더 보내는 쪽 : client 종류에 따라 server 또는 브라우저 client 인증 : o",
"rules": [
{
"title": "Authorization Endpoint에는 client_secret을 보내지 않는다",
"body": "Authorization request는 브라우저 navigation으로 전송되므로 URL이 주소창과 브라우저 히스토리, Authorization Server 접근 로그에 기록될 수 있고 이후 navigation에서는 Referrer-Policy 설정에 따라 referrer에도 포함될 수 있다. 여기 실리는 값은 client_id, redirect_uri, response_type, scope, state, code_challenge, code_challenge_method다. secret이 필요한 인증은 아직 하지 않는다.\n\n따라서 authorization request URL에는 노출돼도 되는 값만 포함한다."
},
{
"title": "Token Endpoint에서 비로소 client를 인증한다",
"body": "token request는 authorization code와 `redirect_uri`, `code_verifier` 등을 request body로 보내고, confidential client는 `client_secret_basic` 같은 방식으로 token endpoint에서 client 인증도 수행한다.\n\n주소창과 히스토리에 남지 않는다는 뜻이지 어디에도 기록되지 않는다는 뜻은 아니다. 애플리케이션 debug 로그, reverse proxy 로그, tracing과 APM, packet capture에 남을 수 있어서 credential masking을 따로 둔다.\n\n이 요청을 누가 보내는지는 client 종류에 따라 갈린다. server가 보내면 server-to-server이고, secret이 없는 SPA가 보내면 브라우저가 직접 보낸다. token endpoint를 server 안에서만 부르게 하려면 client 종류부터 confidential로 정해야 한다."
},
{
"title": "PKCE는 두 요청을 같은 주체에 묶는다",
"body": "처음 요청에 code_challenge를 담아서 보내고, 교환할 때 원본인 code_verifier를 보내서 이 두개가 일치하는지 확인 한다.\n이 2개가 일치해야 토큰 교환이 되게 된다.\n\ncode를 누가 훔쳐 가도 verifier가 없으면 token으로 바꾸지 못한다."
},
{
"title": "issuer 검증값과 JWK 조회 주소를 같은 값으로 맞추려 하지 않는다",
"body": "issuer는 요청을 보내는 주소가 아니라 token의 canonical issuer identifier다. 검증은 발급된 token의 iss claim이 그 값과 같은지를 본다.\n\nJWK 조회 주소는 실제로 공개키를 가져오는 network 경로다. 이 예제에서는 브라우저가 보는 주소와 컨테이너 안에서 닿는 주소가 다르다. 컨테이너 안에서는 자기 localhost가 그 서버가 아니므로 service 이름을 써야 하고, 브라우저는 그 이름에 닿지 못한다.\n\nissuer 검증값과 endpoint 연결 주소는 따로 구성한다. 둘을 하나로 맞추려 하면 로그인 redirect가 깨지거나 서버가 키를 못 가져온다."
},
{
"title": "Resource API는 서명만 보고 끝내지 않는다",
"body": "서명이 맞다는 것은 그 IdP가 발급했다는 뜻일 뿐이다. 같은 IdP가 다른 API용으로 발급한 token도 서명은 맞다.\n\nResource Server는 서명과 함께 issuer, 유효 시간, audience를 검증한다. 특히 audience를 검증해야 다른 resource를 대상으로 발급된 token을 현재 API에서 받아들이지 않는다."
},
{
"title": "redirect_uri는 exact match로 좁힌다",
"body": "wildcard allowlist는 학습 환경에서 편하다. 다만 허용 범위가 넓으면 같은 호스트의 다른 경로로도 code가 갈 수 있다.\n\n실제로 쓰는 callback 주소만 등록해 두면 code가 도착할 수 있는 곳이 그 하나로 줄어든다.\n\n등록하지 않은 redirect_uri를 보냈을 때 거부하는지 확인하는 검사도 따로 둔다."
},
{
"title": "로그인 구간과 API 호출 구간을 한 줄로 그리지 않는다",
"body": "로그인 구간은 authorization request에서 시작해 callback과 code 교환을 지나 로그인 상태를 만드는 데까지다. API 호출 구간은 브라우저 입력, 중간 계층의 credential 변환, 보호 자원의 검증, 최종 응답이다.\n\n로그인 구간과 애플리케이션 API 호출 구간은 호출 주체가 다를 수 있으므로 별도로 그린다. 그래야 code 교환 주체와 Resource Server 호출 주체를 각각 확인할 수 있다."
}
],
"verifiedOn": "2026-08-25",
"applyWhen": [
"Authorization Code Flow를 쓰는 client를 설정하거나 문서로 설명할 때",
"브라우저 요청과 server-to-server 요청이 한 흐름에 섞여 있을 때",
"endpoint별로 무엇이 노출되는지 나눠야 할 때",
"PKCE와 client 인증의 자리를 정할 때"
],
"exceptions": [
"Client Credentials처럼 사용자 없이 token을 받는 흐름은 Authorization Endpoint를 지나지 않는다.",
"Device Authorization Grant는 브라우저 redirect 대신 별도의 사용자 code 단계를 쓴다. redirect_uri 항목이 그대로 적용되지 않는다."
],
"examples": [
"authorization request에는 code_challenge_method=S256이 있고 client secret은 없다",
"token request에는 code_verifier가 있다. secret을 가진 client는 이 요청에서 자기를 인증한다",
"expected issuer는 http://localhost:8080/realms/keycloak-patterns 이고 JWK 조회는 컨테이너 network 주소를 쓴다",
"audience에 keycloak-pattern-api 가 없으면 invalid_token 결과가 되어 401이 된다",
"redirect allowlist에 wildcard가 있으면 등록한 host의 다른 경로로도 code가 갈 수 있다"
]
}
@@ -0,0 +1,111 @@
---
id: 39fdf472-82c4-43ed-abec-73de672f08ae
kind: REFERENCE
slug: authorization-code-endpoint-credential-movement
title: Authorization Code Flow의 Endpoint와 Credential 이동 기준
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 중
version: 32
verifiedOn: 2026-08-25
studio: "https://hyeonworks.com/studio/documents/39fdf472-82c4-43ed-abec-73de672f08ae/edit"
public: "https://hyeonworks.com/references/authorization-code-endpoint-credential-movement"
---
# Authorization Code Flow의 Endpoint와 Credential 이동 기준
Authorization Code Flow에서 브라우저와 client, Authorization Server, Resource Server가 주고받는 값을 endpoint별로 정리한다. 특히 `client_secret`과 authorization code, access token이 어느 요청에 포함되는지를 구분한다.
## 관계
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
브라우저가 code를 직접 교환하는 흐름에서 endpoint별 이동을 관측했다.
- **Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조**
confidential client가 token endpoint에서 client 인증을 수행하는 흐름을 보여 준다.
- **Public Client와 Confidential Client 구분 기준**
public/confidential client 구분에 따라 token endpoint의 client 인증 방식이 달라지고, Authorization Code Flow에서는 PKCE 적용 여부도 함께 결정한다.
## 목적
Authorization Endpoint와 Token Endpoint는 역할과 호출 방식이 다르다.
이 구분을 해야 SPA에서 client_secret이 어디로 갔는지, PKCE가 어느 구간을 지키는지 이해하기 쉽다.
하나는 브라우저의 full-page navigation이고 하나는 server-to-server 호출이 될 수도 있고 browser-to-server 호출이 될 수도 있다.
노출되는 것도, 인증하는 방법도 다르다.
Authorization Endpoint
경로 : 브라우저 주소창 남는 곳 : 히스토리·서버 로그·referrer client 인증 : x
Token Endpoint
경로 : body와 Authorization 헤더 보내는 쪽 : client 종류에 따라 server 또는 브라우저 client 인증 : o
## 규칙
### 1. Authorization Endpoint에는 client_secret을 보내지 않는다
Authorization request는 브라우저 navigation으로 전송되므로 URL이 주소창과 브라우저 히스토리, Authorization Server 접근 로그에 기록될 수 있고 이후 navigation에서는 Referrer-Policy 설정에 따라 referrer에도 포함될 수 있다. 여기 실리는 값은 client_id, redirect_uri, response_type, scope, state, code_challenge, code_challenge_method다. secret이 필요한 인증은 아직 하지 않는다.
따라서 authorization request URL에는 노출돼도 되는 값만 포함한다.
### 2. Token Endpoint에서 비로소 client를 인증한다
token request는 authorization code와 `redirect_uri`, `code_verifier` 등을 request body로 보내고, confidential client는 `client_secret_basic` 같은 방식으로 token endpoint에서 client 인증도 수행한다.
주소창과 히스토리에 남지 않는다는 뜻이지 어디에도 기록되지 않는다는 뜻은 아니다. 애플리케이션 debug 로그, reverse proxy 로그, tracing과 APM, packet capture에 남을 수 있어서 credential masking을 따로 둔다.
이 요청을 누가 보내는지는 client 종류에 따라 갈린다. server가 보내면 server-to-server이고, secret이 없는 SPA가 보내면 브라우저가 직접 보낸다. token endpoint를 server 안에서만 부르게 하려면 client 종류부터 confidential로 정해야 한다.
### 3. PKCE는 두 요청을 같은 주체에 묶는다
처음 요청에 code_challenge를 담아서 보내고, 교환할 때 원본인 code_verifier를 보내서 이 두개가 일치하는지 확인 한다.
이 2개가 일치해야 토큰 교환이 되게 된다.
code를 누가 훔쳐 가도 verifier가 없으면 token으로 바꾸지 못한다.
### 4. issuer 검증값과 JWK 조회 주소를 같은 값으로 맞추려 하지 않는다
issuer는 요청을 보내는 주소가 아니라 token의 canonical issuer identifier다. 검증은 발급된 token의 iss claim이 그 값과 같은지를 본다.
JWK 조회 주소는 실제로 공개키를 가져오는 network 경로다. 이 예제에서는 브라우저가 보는 주소와 컨테이너 안에서 닿는 주소가 다르다. 컨테이너 안에서는 자기 localhost가 그 서버가 아니므로 service 이름을 써야 하고, 브라우저는 그 이름에 닿지 못한다.
issuer 검증값과 endpoint 연결 주소는 따로 구성한다. 둘을 하나로 맞추려 하면 로그인 redirect가 깨지거나 서버가 키를 못 가져온다.
### 5. Resource API는 서명만 보고 끝내지 않는다
서명이 맞다는 것은 그 IdP가 발급했다는 뜻일 뿐이다. 같은 IdP가 다른 API용으로 발급한 token도 서명은 맞다.
Resource Server는 서명과 함께 issuer, 유효 시간, audience를 검증한다. 특히 audience를 검증해야 다른 resource를 대상으로 발급된 token을 현재 API에서 받아들이지 않는다.
### 6. redirect_uri는 exact match로 좁힌다
wildcard allowlist는 학습 환경에서 편하다. 다만 허용 범위가 넓으면 같은 호스트의 다른 경로로도 code가 갈 수 있다.
실제로 쓰는 callback 주소만 등록해 두면 code가 도착할 수 있는 곳이 그 하나로 줄어든다.
등록하지 않은 redirect_uri를 보냈을 때 거부하는지 확인하는 검사도 따로 둔다.
### 7. 로그인 구간과 API 호출 구간을 한 줄로 그리지 않는다
로그인 구간은 authorization request에서 시작해 callback과 code 교환을 지나 로그인 상태를 만드는 데까지다. API 호출 구간은 브라우저 입력, 중간 계층의 credential 변환, 보호 자원의 검증, 최종 응답이다.
로그인 구간과 애플리케이션 API 호출 구간은 호출 주체가 다를 수 있으므로 별도로 그린다. 그래야 code 교환 주체와 Resource Server 호출 주체를 각각 확인할 수 있다.
## 적용 조건
- Authorization Code Flow를 쓰는 client를 설정하거나 문서로 설명할 때
- 브라우저 요청과 server-to-server 요청이 한 흐름에 섞여 있을 때
- endpoint별로 무엇이 노출되는지 나눠야 할 때
- PKCE 적용과 client 인증 방식을 정할 때
## 예외
- Client Credentials처럼 사용자 없이 token을 받는 흐름은 Authorization Endpoint를 지나지 않는다.
- Device Authorization Grant는 브라우저 redirect가 아니라 device code와 user code를 사용하므로 이 문서의 `redirect_uri` 흐름과는 별도로 본다.
## 예시
- authorization request에는 code_challenge_method=S256이 있고 client secret은 없다
- token request에는 code_verifier가 있다. secret을 가진 client는 이 요청에서 자기를 인증한다
- expected issuer는 http://localhost:8080/realms/keycloak-patterns 이고 JWK 조회는 컨테이너 network 주소를 쓴다
- audience에 keycloak-pattern-api 가 없으면 invalid_token 결과가 되어 401이 된다
- redirect allowlist에 wildcard가 있으면 등록한 host의 다른 경로로도 code가 갈 수 있다
@@ -0,0 +1,52 @@
{
"kind": "REFERENCE",
"title": "BFF 인증 구조 설계 기준",
"slug": "bff-authentication-design-criteria",
"summary": "BFF가 OAuth token을 server-side에서 관리하고 브라우저는 session cookie로 BFF를 호출할 때 필요한 설계 항목을 정리한다. CSRF 검증, authorized client 저장소, logout, downstream 오류 처리가 핵심이다.",
"purpose": "BFF 구조에서는 BFF가 authorization code를 token으로 교환하고 access token을 사용해 Resource Server를 호출한다. 따라서 session과 authorized client를 함께 관리하는 보안 구성요소로 본다.\n\ncookie가 credential이 되면 브라우저가 요청마다 자동으로 붙인다. 값을 바꾸는 요청은 사용자의 의도인지 따로 확인해야 한다. 그리고 재시작과 replica 이동을 견딜 저장소도 함께 필요해진다.\n\n여기 있는 것은 「BFF를 쓴다」로 답이 되지 않는 항목들이다.",
"rules": [
{
"title": "브라우저에는 session cookie만 남긴다",
"body": "access token과 refresh token은 server-side authorized client에 보관한다. 브라우저가 token을 직접 사용할 필요가 없도록 BFF가 downstream 요청의 `Authorization` 헤더를 만든다.\n\nsession cookie는 downstream으로 전달하지 않는다. BFF가 session을 애플리케이션 credential로 소비하고, Resource Server가 아는 Bearer 요청을 새로 만든다. 두 credential은 같은 요청 처리 안에 있지만 검증하는 주체가 다르다."
},
{
"title": "cookie가 credential이면 상태 변경 요청에 CSRF 검증을 둔다",
"body": "session cookie는 브라우저가 자동으로 전송하므로 상태 변경 endpoint에는 CSRF 검증을 적용한다. 현재 구성은 JavaScript가 CSRF cookie를 읽어 요청 헤더에 같은 값을 전달하는 방식을 사용한다.\n\n노출 값과 제출 값이 다를 수 있다. 응답 본문의 token이 가려진 값이면 헤더에 넣는 값은 cookie에서 읽어야 한다. 두 값을 같다고 가정하고 구현하면 클라이언트가 그대로 403을 받는다.\n\nSameSite와 CSRF token은 역할이 다르다. SameSite는 특정 cross-site 요청에서 cookie 전송을 제한하는 브라우저 정책이고, CSRF token은 cookie가 포함된 상태 변경 요청을 서버가 추가로 검증하는 값이다. 같은 site로 계산되는 다른 origin 요청도 고려해야 한다."
},
{
"title": "session과 authorized client의 수명주기를 따로 설계한다",
"body": "session은 session ID로 조회하고 authorized client는 registration 이름과 principal name으로 조회한다. shared store를 도입할 때 두 저장 구조를 각각 확인해야 한다.\n\n같은 사용자가 두 브라우저에서 로그인하면 같은 token 항목을 공유하거나 덮어쓴다. session ID마다 token을 따로 보관해야 하면 그렇게 설계해야 한다.\n\n저장소는 재시작과 replica 이동을 견뎌야 한다. 공유 durable store와 session affinity, 저장 token 암호화 중 무엇을 쓸지 정하고 암호화 key 교체 방법도 같이 정한다.\n\nlogout에서는 application session과 authorized client를 모두 정리한다. 두 상태의 lookup key가 다르므로 삭제 처리도 각각 확인해야 한다."
},
{
"title": "downstream 오류를 화면 오류로 바꾸는 규칙을 둔다",
"body": "Resource Server의 401을 그대로 내려보내면 사용자는 로그인이 끊긴 것인지 권한이 없는 것인지 알 수 없다. timeout과 retry, circuit breaker, 재로그인 전환도 함께 정한다. 모든 UI 요청이 BFF를 지나기 때문에 여기서 정하지 않으면 화면마다 다르게 처리된다."
},
{
"title": "자기 보고 값을 증거로 쓰지 않는다",
"body": "「브라우저에 token이 없다」고 서버가 응답에 적는 값은 서버가 넣은 상수다. 브라우저를 들여다본 결과가 아니다.\n\n진단 endpoint의 응답과 별개로 브라우저 개발자 도구에서 network 요청과 Web Storage를 직접 확인한다. 애플리케이션이 스스로 보고한 값과 브라우저에서 관측한 결과를 구분해 기록한다."
},
{
"title": "BFF를 넣어도 XSS는 남는다",
"body": "same-origin에서 악성 script가 실행되면 피해자 session으로 BFF endpoint를 호출하고 JavaScript에서 읽을 수 있는 CSRF cookie에도 접근할 수 있다. BFF는 OAuth token 원문을 브라우저 JavaScript에 전달하지 않지만, CSP와 output encoding, 의존성 무결성, 애플리케이션 인가는 별도로 적용해야 한다."
}
],
"verifiedOn": null,
"applyWhen": [
"브라우저가 OAuth token을 받아서는 안 될 때",
"backend가 화면에 맞춰 여러 API를 조합해야 할 때",
"로그인 상태를 애플리케이션이 소유해야 할 때",
"downstream API가 늘어나도 브라우저는 하나만 알게 하고 싶을 때"
],
"exceptions": [
"stateless 직접 API 호출과 독립 client가 핵심이면 BFF를 넣지 않는다. server state와 단일 장애 지점만 늘어난다.",
"브라우저의 직접 API 호출을 남겨야 하면 refresh credential만 서버로 분리하는 구조가 맞다.",
"server state를 둘 수 없는 환경이면 브라우저가 token을 직접 다루는 구조가 더 단순하다."
],
"examples": [
"브라우저 요청에는 Authorization 헤더가 없고 session cookie만 있다",
"BFF가 authorized client에서 access token을 읽어 downstream Bearer 요청을 새로 만든다",
"CSRF 헤더가 없는 POST는 403이 되고 cookie의 raw 값을 헤더에 넣은 POST는 200이 된다",
"응답 본문의 token은 가려진 값이고 헤더에 넣는 값은 cookie의 raw 값이다",
"진단 endpoint의 browserTokenCount는 controller literal이라서 token 비노출의 근거가 아니다"
]
}
@@ -0,0 +1,95 @@
---
id: 97eddd97-1096-426a-a2c6-a6c5bf1cd09f
kind: REFERENCE
slug: bff-authentication-design-criteria
title: BFF 인증 구조 설계 기준
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 전
version: 10
studio: "https://hyeonworks.com/studio/documents/97eddd97-1096-426a-a2c6-a6c5bf1cd09f/edit"
---
# BFF 인증 구조 설계 기준
BFF가 OAuth token을 server-side에서 관리하고 브라우저는 session cookie로 BFF를 호출할 때 필요한 설계 항목을 정리한다. CSRF 검증, authorized client 저장소, logout, downstream 오류 처리가 핵심이다.
## 관계
- **BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정**
이 기준의 항목 중 실제로 구현된 것과 비어 있는 것을 센 기록이다.
- **서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가**
저장소 항목이 아직 답이 없는 질문으로 남아 있다.
- **BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가**
어느 저장소에 둘지가 이 기준의 미결 항목이다.
- **BFF가 OAuth Token을 관리하는 조건**
이 결정이 PROPOSED인 동안 실제 적용 기준은 이 문서다.
## 목적
BFF 구조에서는 BFF가 authorization code를 token으로 교환하고 access token을 사용해 Resource Server를 호출한다. 따라서 session과 authorized client를 함께 관리하는 보안 구성요소로 본다.
cookie가 credential이 되면 브라우저가 요청마다 자동으로 붙인다. 값을 바꾸는 요청은 사용자의 의도인지 따로 확인해야 한다. 그리고 재시작과 replica 이동을 견딜 저장소도 함께 필요해진다.
여기 있는 것은 「BFF를 쓴다」로 답이 되지 않는 항목들이다.
## 규칙
### 1. 브라우저에는 OAuth token을 전달하지 않는다
access token과 refresh token은 server-side authorized client에 보관한다. 브라우저가 token을 직접 사용할 필요가 없도록 BFF가 downstream 요청의 `Authorization` 헤더를 만든다.
session cookie는 downstream으로 전달하지 않는다. BFF가 session을 애플리케이션 credential로 소비하고, Resource Server가 아는 Bearer 요청을 새로 만든다. 두 credential은 같은 요청 처리 안에 있지만 검증하는 주체가 다르다.
### 2. cookie가 credential이면 상태 변경 요청에 CSRF 검증을 둔다
session cookie는 브라우저가 자동으로 전송하므로 상태 변경 endpoint에는 CSRF 검증을 적용한다. 현재 구성은 JavaScript가 CSRF cookie를 읽어 요청 헤더에 같은 값을 전달하는 방식을 사용한다.
노출 값과 제출 값이 다를 수 있다. 응답 본문의 token이 가려진 값이면 헤더에 넣는 값은 cookie에서 읽어야 한다. 두 값을 같다고 가정하고 구현하면 클라이언트가 그대로 403을 받는다.
SameSite와 CSRF token은 역할이 다르다. SameSite는 특정 cross-site 요청에서 cookie 전송을 제한하는 브라우저 정책이고, CSRF token은 cookie가 포함된 상태 변경 요청을 서버가 추가로 검증하는 값이다. 같은 site로 계산되는 다른 origin 요청도 고려해야 한다.
### 3. session과 authorized client의 수명주기를 따로 설계한다
session은 session ID로 조회하고 authorized client는 registration 이름과 principal name으로 조회한다. shared store를 도입할 때 두 저장 구조를 각각 확인해야 한다.
같은 사용자가 두 브라우저에서 로그인하면 같은 token 항목을 공유하거나 덮어쓴다. session ID마다 token을 따로 보관해야 하면 그렇게 설계해야 한다.
저장소는 재시작과 replica 이동을 견뎌야 한다. 공유 durable store와 session affinity, 저장 token 암호화 중 무엇을 쓸지 정하고 암호화 key 교체 방법도 같이 정한다.
logout에서는 application session과 authorized client를 모두 정리한다. 두 상태의 lookup key가 다르므로 삭제 처리도 각각 확인해야 한다.
### 4. downstream 오류를 화면 오류로 바꾸는 규칙을 둔다
Resource Server의 401을 그대로 내려보내면 사용자는 로그인이 끊긴 것인지 권한이 없는 것인지 알 수 없다. timeout과 retry, circuit breaker, 재로그인 전환도 함께 정한다. 모든 UI 요청이 BFF를 지나기 때문에 여기서 정하지 않으면 화면마다 다르게 처리된다.
### 5. 자기 보고 값을 증거로 쓰지 않는다
「브라우저에 token이 없다」고 서버가 응답에 적는 값은 서버가 넣은 상수다. 브라우저를 들여다본 결과가 아니다.
진단 endpoint의 응답과 별개로 브라우저 개발자 도구에서 network 요청과 Web Storage를 직접 확인한다. 애플리케이션이 스스로 보고한 값과 브라우저에서 관측한 결과를 구분해 기록한다.
### 6. BFF에서도 XSS 방어는 별도로 필요하다
same-origin에서 악성 script가 실행되면 피해자 session으로 BFF endpoint를 호출하고 JavaScript에서 읽을 수 있는 CSRF cookie에도 접근할 수 있다. BFF는 OAuth token 원문을 브라우저 JavaScript에 전달하지 않지만, CSP와 output encoding, 의존성 무결성, 애플리케이션 인가는 별도로 적용해야 한다.
## 적용 조건
- 브라우저가 OAuth token을 받아서는 안 될 때
- backend가 화면에 맞춰 여러 API를 조합해야 할 때
- 로그인 상태를 애플리케이션이 소유해야 할 때
- downstream API가 늘어나도 브라우저는 하나만 알게 하고 싶을 때
## 예외
- stateless 직접 API 호출과 독립 client가 핵심이면 BFF를 넣지 않는다. server state와 단일 장애 지점만 늘어난다.
- 브라우저의 직접 API 호출을 남겨야 하면 refresh credential만 서버로 분리하는 구조가 맞다.
- server state를 둘 수 없는 환경이면 브라우저가 token을 직접 다루는 구조가 더 단순하다.
## 예시
- 브라우저 요청에는 Authorization 헤더가 없고 session cookie만 있다
- BFF가 authorized client에서 access token을 읽어 downstream Bearer 요청을 새로 만든다
- CSRF 헤더가 없는 POST는 403이 되고 cookie의 raw 값을 헤더에 넣은 POST는 200이 된다
- 응답 본문의 token은 가려진 값이고 헤더에 넣는 값은 cookie의 raw 값이다
- 진단 endpoint의 browserTokenCount는 controller literal이라서 token 비노출의 근거가 아니다
@@ -0,0 +1,60 @@
{
"kind": "REFERENCE",
"title": "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건",
"slug": "forward-auth-identity-header-trust",
"summary": "upstream이 사용자를 판단하는 근거가 헤더 하나뿐인 구조에서, 그 헤더를 믿을 수 있게 만드는 조건을 모았다. 외부 경로 차단, 동명 헤더 덮어쓰기, internal credential 검증이 서로 다른 곳에 함께 있어야 한다.",
"purpose": "외부 요청이 edge를 지나 인증되고 upstream으로 가는 구조에서, upstream이 사용자를 판단하는 근거는 헤더 하나다.\n\n같은 이름의 헤더를 인증을 마친 edge가 만들 수도 있고 공격자가 요청에 직접 적어 보낼 수도 있다. upstream이 받는 요청에서 이 둘은 구분되지 않는다.\n\nidentity header를 upstream에서 사용하려면 먼저 그 헤더가 edge를 통해 생성됐음을 보장하는 경로와 검증 방법을 정한다.",
"rules": [
{
"title": "외부에서 upstream과 auth proxy에 직접 닿지 못하게 한다",
"body": "edge만 공개하고 나머지는 내부 network에 두면서 host port로 노출하지 않는다.\n\n이걸 안 하면 공격자가 edge를 건너뛰고 upstream을 직접 부른다. 그때는 헤더를 아무리 검사해도 공격자가 그 헤더를 마음대로 쓸 수 있어서 의미가 없다."
},
{
"title": "client가 보낸 동명 헤더를 항상 덮어쓴다",
"body": "merge가 아니라 덮어쓰기로 채우고, 인증 결과에서 복사한 값만 upstream으로 보낸다. merge로 두면 client가 보낸 값이 앞이나 뒤에 함께 붙고, 어느 쪽을 읽을지는 upstream 구현에 달려 있다.\n\ntrusted proxy 범위도 같이 좁힌다. 넓게 잡으면 같은 network 안의 다른 workload가 edge인 척할 수 있고, forwarded 계열 헤더를 믿는 설정에서는 그 범위가 곧 신뢰 경계다."
},
{
"title": "auth endpoint는 subrequest 전용으로 둔다",
"body": "이 endpoint는 외부 client가 쓰라고 만든 것이 아니다. proxy가 만드는 subrequest만 들어가게 하고 외부 호출에는 응답하지 않게 둔다. Nginx라면 `internal` location이 그 역할을 한다."
},
{
"title": "upstream이 헤더 존재만 보지 않는다",
"body": "배포 시 주입한 internal credential과 요청 값을 비교한다. 비교 구현은 입력값의 일치 길이에 따라 실행 시간이 크게 달라지지 않는 방식을 사용한다.\n\ninternal credential 검증을 controller마다 반복하면 새 endpoint에서 누락될 수 있다. 운영에서는 filter, interceptor, security chain 등 공통 처리 경로에 적용한다."
},
{
"title": "격리와 헤더 검증은 서로 대신하지 않는다",
"body": "격리는 밖에서 들어오는 직접 접근을 막고 헤더 검증은 안에서 만들어진 위조를 막는다. 막는 대상이 달라서 하나로 다른 하나를 대체했다고 쓸 수 없다."
},
{
"title": "전달할 헤더를 allowlist로 고정한다",
"body": "복사할 응답 헤더 목록을 정해 두고 그 밖은 버린다. 늘릴 때마다 claim 출처와 다중 값 구분자, escaping, 최대 크기, upstream 검증 계약을 다시 정해야 한다.\n\nuser와 email만 전달하는 구조는 누가 왔는지만 말하고 무엇을 해도 되는지는 말하지 않는다. role이 바뀌었을 때 proxy session과 downstream 인가가 언제 따라가는지도 따로 정한다."
},
{
"title": "검사 지점은 요청 실패가 아니라 응답의 사용자다",
"body": "위조 헤더를 얹은 정상 session 요청은 정상 session이니 200이 되는 것이 맞다. 확인할 값은 그 응답의 사용자가 위조 값인지 실제 인증된 사용자인지다. 요청이 실패하는지만 보면 덮어쓰기가 동작하는지 알 수 없다."
},
{
"title": "지금 확인한 것과 운영에서 더 필요한 것을 나눠 적는다",
"body": "이 기준에서 실제 fixture로 확인한 것은 외부 경로 차단, 헤더 덮어쓰기, auth endpoint 내부 전용 지정, upstream의 internal credential 확인이다.\n\n운영에서는 여기에 더 필요하다. 공유 secret을 secret manager에서 주입하고 교체 절차를 두는 것, network policy로 경로를 강제하는 것, 그리고 더 강하게 묶으려면 mTLS나 workload identity를 쓰는 것이다. 두 묶음을 같은 문단에 섞어 적지 않는다."
}
],
"verifiedOn": null,
"applyWhen": [
"upstream에 OAuth client나 JWT 검증 코드를 넣기 어려울 때",
"여러 legacy service 앞에 같은 로그인 정책을 둘 때",
"edge에서 정책을 강제할 수 있을 때",
"이미 forward-auth를 쓰고 있는 구조를 점검할 때"
],
"exceptions": [
"backend 직접 경로나 헤더 덮어쓰기를 닫을 수 없는 환경이면 이 구조를 쓰지 않는다.",
"애플리케이션이 사용자별 API 조합과 세밀한 인가를 직접 맡아야 하면 BFF 구조가 더 자연스럽다.",
"임의 경로와 body, streaming을 그대로 넘기는 범용 reverse proxy가 필요하면 URI rewrite와 timeout, 응답 헤더 처리를 따로 설계해야 한다."
],
"examples": [
"외부에는 edge만 공개하고 app과 auth proxy의 port는 host에 publish하지 않는다",
"정상 session에 위조 헤더를 얹은 요청은 200을 받지만 응답의 사용자는 실제 사용자다",
"외부에서 auth endpoint를 직접 부르면 404가 된다",
"upstream은 user 헤더와 internal token을 함께 확인하고 하나라도 어긋나면 401을 돌려준다",
"내부 검사가 controller 하나에만 있으면 새 endpoint에는 보호가 따라오지 않는다"
]
}
@@ -0,0 +1,97 @@
---
id: 004dd0a2-5fb3-4f25-80c9-576f709de331
kind: REFERENCE
slug: forward-auth-identity-header-trust
title: Forward-Auth에서 Identity Header를 신뢰하기 위한 조건
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 전
version: 10
studio: "https://hyeonworks.com/studio/documents/004dd0a2-5fb3-4f25-80c9-576f709de331/edit"
---
# Forward-Auth에서 Identity Header를 신뢰하기 위한 조건
upstream이 사용자를 판단하는 근거가 헤더 하나뿐인 구조에서, 그 헤더를 믿을 수 있게 만드는 조건을 모았다. 외부 경로 차단, 동명 헤더 덮어쓰기, internal credential 검증이 서로 다른 곳에 함께 있어야 한다.
## 관계
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
이 기준의 다섯 조건을 실제 설정에서 확인한 기록이다.
- **Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가**
헤더를 어디까지 늘릴지가 이 기준의 미결 항목이다.
- **OAuth Token과 Application Session을 구분하는 기준**
identity 헤더를 JWT나 session과 같은 이름으로 부르지 않는다.
## 목적
외부 요청이 edge를 지나 인증되고 upstream으로 가는 구조에서, upstream이 사용자를 판단하는 근거는 헤더 하나다.
같은 이름의 헤더를 인증을 마친 edge가 만들 수도 있고 공격자가 요청에 직접 적어 보낼 수도 있다. upstream이 받는 요청에서 이 둘은 구분되지 않는다.
identity header를 upstream에서 사용하려면 먼저 그 헤더가 edge를 통해 생성됐음을 보장하는 경로와 검증 방법을 정한다.
## 규칙
### 1. 외부에서 upstream과 auth proxy에 직접 닿지 못하게 한다
edge만 공개하고 나머지는 내부 network에 두면서 host port로 노출하지 않는다.
이걸 안 하면 공격자가 edge를 건너뛰고 upstream을 직접 부른다. 그때는 헤더를 아무리 검사해도 공격자가 그 헤더를 마음대로 쓸 수 있어서 의미가 없다.
### 2. client가 보낸 동명 헤더를 항상 덮어쓴다
merge가 아니라 덮어쓰기로 채우고, 인증 결과에서 복사한 값만 upstream으로 보낸다. merge로 두면 client가 보낸 값이 앞이나 뒤에 함께 붙고, 어느 쪽을 읽을지는 upstream 구현에 달려 있다.
trusted proxy 범위도 같이 좁힌다. 넓게 잡으면 같은 network 안의 다른 workload가 edge인 척할 수 있고, forwarded 계열 헤더를 믿는 설정에서는 그 범위가 곧 신뢰 경계다.
### 3. auth endpoint는 subrequest 전용으로 둔다
이 endpoint는 외부 client가 쓰라고 만든 것이 아니다. proxy가 만드는 subrequest만 들어가게 하고 외부 호출에는 응답하지 않게 둔다. Nginx라면 `internal` location이 그 역할을 한다.
### 4. upstream이 헤더 존재만 보지 않는다
배포 시 주입한 internal credential과 요청 값을 비교한다. 비교 구현은 입력값의 일치 길이에 따라 실행 시간이 크게 달라지지 않는 방식을 사용한다.
internal credential 검증을 controller마다 반복하면 새 endpoint에서 누락될 수 있다. 운영에서는 filter, interceptor, security chain 등 공통 처리 경로에 적용한다.
### 5. Network 격리와 헤더 검증을 모두 적용한다
격리는 밖에서 들어오는 직접 접근을 막고 헤더 검증은 안에서 만들어진 위조를 막는다. 막는 대상이 달라서 하나로 다른 하나를 대체했다고 쓸 수 없다.
### 6. 전달할 헤더를 allowlist로 고정한다
복사할 응답 헤더 목록을 정해 두고 그 밖은 버린다. 늘릴 때마다 claim 출처와 다중 값 구분자, escaping, 최대 크기, upstream 검증 계약을 다시 정해야 한다.
user와 email만 전달하는 구조는 누가 왔는지만 말하고 무엇을 해도 되는지는 말하지 않는다. role이 바뀌었을 때 proxy session과 downstream 인가가 언제 따라가는지도 따로 정한다.
### 7. 검사 지점은 요청 실패가 아니라 응답의 사용자다
위조 헤더를 얹은 정상 session 요청은 정상 session이니 200이 되는 것이 맞다. 확인할 값은 그 응답의 사용자가 위조 값인지 실제 인증된 사용자인지다. 요청이 실패하는지만 보면 덮어쓰기가 동작하는지 알 수 없다.
### 8. 지금 확인한 것과 운영에서 더 필요한 것을 나눠 적는다
이 기준에서 실제 fixture로 확인한 것은 외부 경로 차단, 헤더 덮어쓰기, auth endpoint 내부 전용 지정, upstream의 internal credential 확인이다.
운영에서는 여기에 더 필요하다. 공유 secret을 secret manager에서 주입하고 교체 절차를 두는 것, network policy로 경로를 강제하는 것, 그리고 더 강하게 묶으려면 mTLS나 workload identity를 쓰는 것이다. 두 묶음을 같은 문단에 섞어 적지 않는다.
## 적용 조건
- upstream에 OAuth client나 JWT 검증 코드를 넣기 어려울 때
- 여러 legacy service 앞에 같은 로그인 정책을 둘 때
- edge에서 정책을 강제할 수 있을 때
- 이미 forward-auth를 쓰고 있는 구조를 점검할 때
## 예외
- backend 직접 경로나 헤더 덮어쓰기를 닫을 수 없는 환경이면 이 구조를 쓰지 않는다.
- 애플리케이션이 사용자별 API 조합과 세밀한 인가를 직접 맡아야 하면 BFF 구조가 더 자연스럽다.
- 임의 경로와 body, streaming을 그대로 넘기는 범용 reverse proxy가 필요하면 URI rewrite와 timeout, 응답 헤더 처리를 따로 설계해야 한다.
## 예시
- 외부에는 edge만 공개하고 app과 auth proxy의 port는 host에 publish하지 않는다
- 정상 session에 위조 헤더를 얹은 요청은 200을 받지만 응답의 사용자는 실제 사용자다
- 외부에서 auth endpoint를 직접 부르면 404가 된다
- upstream은 user 헤더와 internal token을 함께 확인하고 하나라도 어긋나면 401을 돌려준다
- 내부 검사가 controller 하나에만 있으면 새 endpoint에는 보호가 따라오지 않는다
@@ -0,0 +1,42 @@
{
"kind": "REFERENCE",
"title": "외부 IdP Federation과 Application 인증 경계",
"slug": "external-idp-federation-application-boundary",
"summary": "Google 로그인은 다섯 번째 인증 구조가 아니다. Google에서 브로커의 identity brokering과 local session, authorization code를 지나면 애플리케이션이 고르는 것은 여전히 앞의 네 경계 중 하나다.",
"purpose": "외부 IdP를 붙이면서 그것을 애플리케이션 인증 구조로 세게 되면 upstream IdP 경계와 애플리케이션 OAuth 경계를 같은 기준으로 묶게 된다.\n\nGoogle은 브로커 앞의 upstream identity provider다. 사용자가 브로커 로그인 화면에서 Google을 고르면 브라우저가 upstream authorization을 하게 되고, 브로커가 그 응답을 검증해 local identity와 연결한 뒤 다시 자기가 만든 authorization code를 애플리케이션으로 보내게 된다.\n\n외부 IdP를 추가해도 애플리케이션 쪽에서 브라우저가 token을 받는지, 어느 계층이 API를 호출하는지는 기존 패턴 선택에 따라 결정한다.",
"rules": [
{
"title": "외부 IdP는 브로커 앞단이고 애플리케이션 경계는 그 뒤다",
"body": "외부 IdP는 브로커 앞의 provider다. 애플리케이션이 고르는 것은 브로커 뒤의 경계이고, 구조 수를 셀 때 외부 IdP를 목록에 넣으면 성격이 다른 것이 섞인다.\n\nupstream IdP의 identity assertion은 Keycloak이 검증한다. 애플리케이션은 Keycloak이 발급한 authorization code와 token을 사용하고 Resource Server도 Keycloak issuer를 검증하므로 애플리케이션의 OAuth 처리 방식은 기존 패턴을 그대로 따른다.\n\nUI에서 provider를 고르게 하거나 provider별 계정 연결을 다루는 것은 자연스럽다. 다만 Resource Server의 token 검증이나 애플리케이션 인가가 upstream IdP별로 갈리기 시작하면 브로커 경계가 애플리케이션까지 새고 있는지 본다.\n\n외부 IdP의 token을 애플리케이션이 직접 받아 검증하는 경로를 만들면 브로커가 하던 계정 연결과 정책 판단이 함께 빠진다."
},
{
"title": "stable identity key는 provider와 upstream subject의 조합이다",
"body": "email은 바뀔 수 있고 다른 계정과 겹칠 수도 있어서 계정을 잇는 열쇠로 맞지 않는다. 어느 provider의 어느 subject인지를 열쇠로 쓴다. email을 열쇠로 쓰면 사용자가 주소를 바꾼 순간 다른 사람이 된다."
},
{
"title": "email 충돌은 별도의 계정 연결 문제로 다룬다",
"body": "upstream email이 기존 계정과 같다는 이유로 자동 병합하지 않는다. 같은 주소를 쓰는 다른 사람일 수도 있고 주소를 선점한 공격일 수도 있어서, 기존 계정의 소유권을 증명하는 절차를 따로 둔다."
},
{
"title": "mock provider로 확인한 범위와 실제 IdP를 구분한다",
"body": "브로커와 claim mapping 계약까지만 확인했다. 실제 계정과 공개 HTTPS callback, consent 화면, 도메인 정책은 아직 통과해 보지 않았다. 두 범위를 같은 증거로 쓰면 운영에서 처음 보는 실패를 만난다."
}
],
"verifiedOn": null,
"applyWhen": [
"외부 IdP를 붙이며 구조 수를 세려 할 때",
"계정 연결 규칙을 정할 때",
"검증 범위를 문서로 적을 때",
"브로커를 거치는 흐름과 직접 OIDC 흐름을 비교할 때"
],
"exceptions": [
"애플리케이션이 브로커를 거치지 않고 외부 IdP와 직접 OIDC를 하는 구조라면 그 IdP가 애플리케이션의 issuer가 된다. 그때는 client 종류와 endpoint 기준을 그대로 적용한다.",
"조직 계정만 쓰고 외부 IdP가 하나뿐이면 브로커를 두지 않는 선택도 있다. 그때는 계정 연결 규칙이 필요하지 않다."
],
"examples": [
"Google 로그인을 추가해도 애플리케이션이 고르는 것은 여전히 네 경계 중 하나다",
"브로커가 provider alias와 upstream subject로 account identity를 정한다",
"애플리케이션이 신뢰하는 issuer는 외부 IdP가 아니라 브로커다",
"mock OIDC provider로 확인한 것은 브로커와 claim mapping 계약까지다"
]
}
@@ -0,0 +1,75 @@
---
id: 1a00a640-8987-4075-a9e4-7ec023cdffbb
kind: REFERENCE
slug: external-idp-federation-application-boundary
title: 외부 IdP Federation과 Application 인증 경계
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 전
version: 9
studio: "https://hyeonworks.com/studio/documents/1a00a640-8987-4075-a9e4-7ec023cdffbb/edit"
---
# 외부 IdP Federation과 Application 인증 경계
Google 로그인은 다섯 번째 인증 구조가 아니다. Google에서 브로커의 identity brokering과 local session, authorization code를 지나면 애플리케이션이 고르는 것은 여전히 앞의 네 경계 중 하나다.
## 관계
- **외부 IdP Federation을 별도의 인증 구조로 세지 않는다**
이 기준을 프로젝트 결정으로 굳힌 기록이다.
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
브로커가 만든 authorization code를 애플리케이션이 받는 흐름이다.
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
외부 IdP가 있어도 애플리케이션 쪽 endpoint 이동은 그대로다.
## 목적
외부 IdP를 붙이면서 그것을 애플리케이션 인증 구조로 세게 되면 upstream IdP 경계와 애플리케이션 OAuth 경계를 같은 기준으로 묶게 된다.
Google은 브로커 앞의 upstream identity provider다. 사용자가 브로커 로그인 화면에서 Google을 고르면 브라우저가 upstream authorization을 하게 되고, 브로커가 그 응답을 검증해 local identity와 연결한 뒤 다시 자기가 만든 authorization code를 애플리케이션으로 보내게 된다.
외부 IdP를 추가해도 애플리케이션 쪽에서 브라우저가 token을 받는지, 어느 계층이 API를 호출하는지는 기존 패턴 선택에 따라 결정한다.
## 규칙
### 1. 외부 IdP는 브로커 앞단이고 애플리케이션 경계는 그 뒤다
외부 IdP는 브로커 앞의 provider다. 애플리케이션이 고르는 것은 브로커 뒤의 경계이고, 구조 수를 셀 때 외부 IdP를 목록에 넣으면 성격이 다른 것이 섞인다.
upstream IdP의 identity assertion은 Keycloak이 검증한다. 애플리케이션은 Keycloak이 발급한 authorization code와 token을 사용하고 Resource Server도 Keycloak issuer를 검증하므로 애플리케이션의 OAuth 처리 방식은 기존 패턴을 그대로 따른다.
UI에서 provider를 고르게 하거나 provider별 계정 연결을 다루는 것은 자연스럽다. 다만 Resource Server의 token 검증이나 애플리케이션 인가가 upstream IdP별로 갈리기 시작하면 브로커 경계가 애플리케이션까지 새고 있는지 본다.
외부 IdP의 token을 애플리케이션이 직접 받아 검증하는 경로를 만들면 브로커가 하던 계정 연결과 정책 판단이 함께 빠진다.
### 2. stable identity key는 provider와 upstream subject의 조합이다
email은 바뀔 수 있고 다른 계정과 겹칠 수도 있어서 계정을 잇는 열쇠로 맞지 않는다. 어느 provider의 어느 subject인지를 열쇠로 쓴다. email을 열쇠로 쓰면 사용자가 주소를 바꾼 순간 다른 사람이 된다.
### 3. email 충돌은 별도의 계정 연결 문제로 다룬다
upstream email이 기존 계정과 같다는 이유로 자동 병합하지 않는다. 같은 주소를 쓰는 다른 사람일 수도 있고 주소를 선점한 공격일 수도 있어서, 기존 계정의 소유권을 증명하는 절차를 따로 둔다.
### 4. mock provider로 확인한 범위와 실제 IdP를 구분한다
브로커와 claim mapping 계약까지만 확인했다. 실제 계정과 공개 HTTPS callback, consent 화면, 도메인 정책은 아직 통과해 보지 않았다. 두 범위를 같은 증거로 쓰면 운영에서 처음 보는 실패를 만난다.
## 적용 조건
- 외부 IdP를 붙이며 구조 수를 세려 할 때
- 계정 연결 규칙을 정할 때
- 검증 범위를 문서로 적을 때
- 브로커를 거치는 흐름과 직접 OIDC 흐름을 비교할 때
## 예외
- 애플리케이션이 브로커를 거치지 않고 외부 IdP와 직접 OIDC를 하는 구조라면 그 IdP가 애플리케이션의 issuer가 된다. 그때는 client 종류와 endpoint 기준을 그대로 적용한다.
- 조직 계정만 쓰고 외부 IdP가 하나뿐이면 브로커를 두지 않는 선택도 있다. 그때는 계정 연결 규칙이 필요하지 않다.
## 예시
- Google 로그인을 추가해도 애플리케이션이 고르는 것은 여전히 네 경계 중 하나다
- 브로커가 provider alias와 upstream subject로 account identity를 정한다
- 애플리케이션이 신뢰하는 issuer는 외부 IdP가 아니라 브로커다
- mock OIDC provider로 확인한 것은 브로커와 claim mapping 계약까지다
@@ -0,0 +1,46 @@
{
"kind": "REFERENCE",
"title": "OAuth/OIDC 인증 패턴 선택 기준",
"slug": "oauth-oidc-pattern-selection-criteria",
"summary": "SPA, Mediator, BFF, OAuth2-Proxy는 브라우저의 access token 사용 여부, Resource Server 호출 주체, server-side 인증 상태, 보호 자원이 검증하는 credential, CSRF 처리 위치가 서로 다르다. 패턴 선택에서는 이 다섯 항목을 요구사항과 운영 환경에 맞춰 비교한다.",
"purpose": "브라우저에 token이 덜 보이는 순서는 있다. 그 순서를 보안 등급으로 쓰면 판단이 틀린다.\n\nBFF는 브라우저 token을 없애지만 server session과 공유 저장소를 만든다. Forward-Auth는 애플리케이션의 token custody를 줄이지만 edge 헤더 신뢰와 network 경계를 만든다. 새로 생긴 쪽을 감당할 수 없는 환경이면 앞 구조가 더 안전하다.\n\n번호가 아니라 배치를 본다.",
"rules": [
{
"title": "네 축으로 배치를 적는다",
"body": "구조를 비교할 때는 브라우저 token 전달, Resource Server 호출 주체, server-side 상태, Resource Server의 검증 대상, CSRF 처리 위치를 확인한다.\n\n브라우저가 access token을 받나\nSPA : o Mediator : o BFF : x Forward-Auth : x\n\n브라우저가 보호 자원을 직접 부르나\nSPA : o Mediator : o BFF : x Forward-Auth : x\n\nserver-side token 상태가 있나\nSPA : x Mediator : o BFF : o Forward-Auth : proxy session\n\n보호 자원이 무엇을 검증하나\nSPA : 서명된 JWT Mediator : 서명된 JWT BFF : 서명된 JWT Forward-Auth : edge가 붙인 헤더\n\ncookie가 credential이면 CSRF 검증이 어디에 붙나\nSPA : 해당 없음 Mediator : session endpoint BFF : 상태 변경 endpoint Forward-Auth : proxy cookie 기준\n\n호출 주체와 credential 저장 방식을 정한 뒤에는 401/403, token 갱신 실패, logout을 어느 계층에서 처리할지 정한다."
},
{
"title": "피해야 할 조건을 먼저 확인한다",
"body": "정책상 브라우저에 token을 둘 수 없으면 memory에만 두는 보관은 답이 아니다. backend 직접 경로나 헤더 덮어쓰기를 닫을 수 없으면 edge에 인증을 맡기지 않는다. 이 조건에 걸리면 다른 항목은 볼 필요가 없다."
},
{
"title": "없앤 것과 새로 맡은 것을 같이 적는다",
"body": "선택 결과만 적지 않고 어떤 요구에서 해당 패턴을 선택했는지와 적용하기 어려운 조건도 함께 기록한다."
},
{
"title": "이름으로 운영 속성을 추정하지 않는다",
"body": "BFF나 forward-auth라는 이름은 배치를 말할 뿐이다. 공유 저장소와 장애 복구, session failover, secret 교체가 갖춰져 있는지는 매번 따로 확인한다."
},
{
"title": "옮기는 것은 업그레이드가 아니다",
"body": "패턴을 바꾸면 credential을 저장하고 전달하고 검증하는 주체도 함께 바뀐다. edge header가 계속 늘어나 애플리케이션 도메인 정보까지 전달해야 한다면 BFF에서 인가와 API 조합을 처리하는 구성을 다시 검토할 수 있다."
}
],
"verifiedOn": null,
"applyWhen": [
"인증 구조를 처음 고를 때",
"한 구조에서 다른 구조로 옮기려 할 때",
"구조를 문서로 비교할 때",
"이름만 보고 고른 구조를 다시 검토할 때"
],
"exceptions": [
"요구가 하나로 좁혀지면 비교가 필요 없다. 브라우저에 token을 둘 수 없고 backend가 API를 조합해야 하면 선택지는 하나다.",
"학습이나 시연이 목적이면 운영 속성 비교를 하지 않아도 된다. 그때는 학습 환경이라고 문서에 적어 둔다."
],
"examples": [
"SPA : 브라우저가 code 교환과 token 보관, API 호출을 모두 맡는다",
"Mediator : refresh token은 server에 있고 access token은 응답 본문으로 브라우저에 간다",
"BFF : server가 code 교환·token 관리·API 호출을 담당하고 브라우저는 session cookie로 BFF를 호출한다",
"Forward-Auth : edge가 인증하고 upstream은 edge가 붙인 헤더를 본다"
]
}
@@ -0,0 +1,94 @@
---
id: 3f886154-1b85-407b-bda4-57d28370e745
kind: REFERENCE
slug: oauth-oidc-pattern-selection-criteria
title: OAuth/OIDC 인증 패턴 선택 기준
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 전
version: 10
studio: "https://hyeonworks.com/studio/documents/3f886154-1b85-407b-bda4-57d28370e745/edit"
---
# OAuth/OIDC 인증 패턴 선택 기준
SPA, Mediator, BFF, OAuth2-Proxy는 브라우저의 access token 사용 여부, Resource Server 호출 주체, server-side 인증 상태, 보호 자원이 검증하는 credential, CSRF 처리 위치가 서로 다르다. 패턴 선택에서는 이 다섯 항목을 요구사항과 운영 환경에 맞춰 비교한다.
## 관계
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
브라우저가 code 교환과 token 보관, API 호출을 모두 맡는다.
- **Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조**
mediator가 refresh token을 관리하고 브라우저가 access token으로 API를 직접 호출하는 구성을 확인했다.
- **BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정**
BFF가 code 교환, token 보관, Resource Server 호출을 모두 처리하는 구성을 확인했다.
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
인증이 edge로 가면 보호 자원이 검증하는 것이 JWT에서 헤더로 바뀐다.
- **인증 구조를 보안 성숙도 단계로 취급하지 않는다**
이 기준의 첫 항목을 프로젝트 결정으로 굳힌 기록이다.
## 목적
브라우저에 token이 덜 보이는 순서는 있다. 그 순서를 보안 등급으로 쓰면 판단이 틀린다.
BFF는 브라우저 token을 없애지만 server session과 공유 저장소를 만든다. Forward-Auth는 애플리케이션의 token custody를 줄이지만 edge 헤더 신뢰와 network 경계를 만든다. 새로 생긴 쪽을 감당할 수 없는 환경이면 앞 구조가 더 안전하다.
번호가 아니라 배치를 본다.
## 규칙
### 1. 다섯 항목으로 구조를 비교한다
구조를 비교할 때는 브라우저 token 전달, Resource Server 호출 주체, server-side 상태, Resource Server의 검증 대상, CSRF 처리 위치를 확인한다.
브라우저가 access token을 받나
SPA : o Mediator : o BFF : x Forward-Auth : x
브라우저가 보호 자원을 직접 부르나
SPA : o Mediator : o BFF : x Forward-Auth : x
server-side token 상태가 있나
SPA : x Mediator : o BFF : o Forward-Auth : proxy session
보호 자원이 무엇을 검증하나
SPA : 서명된 JWT Mediator : 서명된 JWT BFF : 서명된 JWT Forward-Auth : edge가 붙인 헤더
cookie가 credential이면 CSRF 검증이 어디에 붙나
SPA : 해당 없음 Mediator : session endpoint BFF : 상태 변경 endpoint Forward-Auth : proxy cookie 기준
호출 주체와 credential 저장 방식을 정한 뒤에는 401/403, token 갱신 실패, logout을 어느 계층에서 처리할지 정한다.
### 2. 피해야 할 조건을 먼저 확인한다
정책상 브라우저에 token을 둘 수 없으면 memory에만 두는 보관은 답이 아니다. backend 직접 경로나 헤더 덮어쓰기를 닫을 수 없으면 edge에 인증을 맡기지 않는다. 이 조건에 걸리면 다른 항목은 볼 필요가 없다.
### 3. 선택 조건과 운영 부담을 함께 기록한다
선택 결과만 적지 않고 어떤 요구에서 해당 패턴을 선택했는지와 적용하기 어려운 조건도 함께 기록한다.
### 4. 이름으로 운영 속성을 추정하지 않는다
BFF나 forward-auth라는 이름은 배치를 말할 뿐이다. 공유 저장소와 장애 복구, session failover, secret 교체가 갖춰져 있는지는 매번 따로 확인한다.
### 5. 옮기는 것은 업그레이드가 아니다
패턴을 바꾸면 credential을 저장하고 전달하고 검증하는 주체도 함께 바뀐다. edge header가 계속 늘어나 애플리케이션 도메인 정보까지 전달해야 한다면 BFF에서 인가와 API 조합을 처리하는 구성을 다시 검토할 수 있다.
## 적용 조건
- 인증 구조를 처음 고를 때
- 한 구조에서 다른 구조로 옮기려 할 때
- 구조를 문서로 비교할 때
- 이름만 보고 고른 구조를 다시 검토할 때
## 예외
- 요구가 하나로 좁혀지면 비교가 필요 없다. 브라우저에 token을 둘 수 없고 backend가 API를 조합해야 하면 선택지는 하나다.
- 학습이나 시연이 목적이면 운영 속성 비교를 하지 않아도 된다. 그때는 학습 환경이라고 문서에 적어 둔다.
## 예시
- SPA : 브라우저가 code 교환과 token 보관, API 호출을 모두 맡는다
- Mediator : refresh token은 server에 있고 access token은 응답 본문으로 브라우저에 간다
- BFF : server가 code 교환·token 관리·API 호출을 담당하고 브라우저는 session cookie로 BFF를 호출한다
- Forward-Auth : edge가 인증하고 upstream은 edge가 붙인 헤더를 본다
@@ -0,0 +1,47 @@
{
"kind": "REFERENCE",
"title": "Public Client와 Confidential Client 구분 기준",
"slug": "public-confidential-client-boundary",
"summary": "client 종류는 secret을 안전하게 보관할 수 있는지로 정한다. SPA는 보관할 곳이 없어 public client로 등록한다. 종류는 secret이 어디 있는지를 말할 뿐이고, 브라우저에 token이 가는지는 따로 정해진다.",
"purpose": "client 종류를 무엇으로 정하는지부터 맞춰야 PKCE와 client 인증을 어디에 둘지 정할 수 있게 된다.\n\n기준은 프레임워크나 언어가 아니라 값이 도달하는 범위다. 브라우저에서 실행되는 코드에 넣은 값은 개발자 도구를 열면 그대로 보이기 때문에 SPA는 secret을 가질 수 없고, server와 BFF는 그 값을 process 밖으로 내보내지 않을 수 있어서 secret을 들고 있게 된다.\n\n여기서 자주 섞이는 것이 하나 있는데, 종류가 confidential이어도 브라우저에 token이 갈 수 있다. 서로 다른 결정이라서 따로 답해야 한다.",
"rules": [
{
"title": "secret을 숨길 수 있는지로 종류를 정한다",
"body": "배포물이나 실행 중 memory에서 사용자가 값을 꺼낼 수 있으면 public client가 되고, server 안에만 두고 응답으로 나가지 않게 할 수 있으면 confidential client다.\n\nnative app은 브라우저가 아니지만 배포물을 뜯으면 값이 나오기 때문에 여기서도 public client로 다루게 된다. 실행 환경의 이름이 아니라 값이 어디까지 가는지로 정한다."
},
{
"title": "public client에서도 Authorization Code Flow에 PKCE를 함께 쓴다",
"body": "PKCE는 client secret을 대체하는 client 인증 방식이 아니다. authorization request에서 만든 verifier와 token request의 verifier를 연결해 탈취된 authorization code의 교환을 어렵게 만든다.\n\n여기서 S256을 쓴다. plain은 challenge가 verifier 그대로라서 중간에서 본 사람이 그대로 쓸 수 있다."
},
{
"title": "confidential client에도 PKCE를 함께 쓸 수 있다",
"body": "client 인증이 있어도 PKCE는 여전히 쓸모가 있다. 두 장치가 막는 구간이 서로 달라서 함께 두면 그만큼 좁아지게 된다.\n\n다만 「Authorization Code를 쓴다」와 「PKCE S256까지 설정으로 고정했다」는 서로 다른 주장이다. 설정과 테스트에서 확인한 범위까지만 말할 수 있다."
},
{
"title": "public client에서는 implicit flow와 direct access grant를 끈다",
"body": "implicit flow는 token을 redirect fragment로 받게 되어서 주소창과 히스토리에 token이 남고, direct access grant는 애플리케이션이 사용자의 아이디와 비밀번호를 직접 받게 되어서 IdP만 알면 되는 값을 애플리케이션이 만지게 된다.\n\n현재 예제에서는 Authorization Code Flow를 사용하므로 implicit flow와 direct access grant를 비활성화했다."
},
{
"title": "종류가 곧 브라우저 token 유무는 아니다",
"body": "confidential client가 code를 교환해도 그 결과인 access token을 응답 본문으로 브라우저에 건넬 수 있고, 실제로 그렇게 도는 구조가 있다.\n\n종류는 secret을 어디에 두는지를 말하고, token 노출은 어느 계층이 API를 부르는지에 따라 갈린다."
}
],
"verifiedOn": null,
"applyWhen": [
"새 OAuth client를 등록할 때",
"SPA와 server 중 어디가 code를 교환할지 정할 때",
"PKCE와 client 인증을 어디에 둘지 정할 때",
"기존 client의 종류가 맞는지 다시 볼 때"
],
"exceptions": [
"같은 서비스가 브라우저용 public client와 server용 confidential client를 따로 등록할 수 있다. 하나로 합치려고 secret을 브라우저로 내보내지는 않는다.",
"backend가 사용자 없이 자기 자격으로 부르는 흐름은 Client Credentials를 쓰는 별도 client다."
],
"examples": [
"SPA용 client : public, standard flow만 켜고 implicit flow와 direct grant는 끈다",
"Mediator용 client : confidential, client_secret_basic으로 token endpoint에서 인증한다",
"BFF용 client : confidential, PKCE S256을 함께 쓴다",
"Proxy용 client : confidential, oauth2-proxy가 secret과 verifier로 code를 교환한다",
"confidential client인 Mediator를 써도 access token은 브라우저 응답에 실릴 수 있다"
]
}
@@ -0,0 +1,84 @@
---
id: ede6b9ce-eeed-40c8-9175-9e8116029395
kind: REFERENCE
slug: public-confidential-client-boundary
title: Public Client와 Confidential Client 구분 기준
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 전
version: 12
studio: "https://hyeonworks.com/studio/documents/ede6b9ce-eeed-40c8-9175-9e8116029395/edit"
---
# Public Client와 Confidential Client 구분 기준
client 종류는 secret을 안전하게 보관할 수 있는지로 정한다. SPA는 보관할 곳이 없어 public client로 등록한다. 종류는 secret이 어디 있는지를 말할 뿐이고, 브라우저에 token이 가는지는 따로 정해진다.
## 관계
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
SPA를 public client로 등록한 이유를 실제 구성에서 확인할 수 있다.
- **Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조**
confidential client를 사용해도 access token 전달 방식은 별도로 설계된다는 예다.
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
client 종류에 따라 token endpoint의 client 인증 방식이 달라진다.
## 목적
client 종류를 무엇으로 정하는지부터 맞춰야 PKCE와 client 인증을 어디에 둘지 정할 수 있게 된다.
기준은 프레임워크나 언어가 아니라 값이 도달하는 범위다. 브라우저에서 실행되는 코드에 넣은 값은 개발자 도구를 열면 그대로 보이기 때문에 SPA는 secret을 가질 수 없고, server와 BFF는 그 값을 process 밖으로 내보내지 않을 수 있어서 secret을 들고 있게 된다.
여기서 자주 섞이는 것이 하나 있는데, 종류가 confidential이어도 브라우저에 token이 갈 수 있다. 서로 다른 결정이라서 따로 답해야 한다.
## 규칙
### 1. secret을 숨길 수 있는지로 종류를 정한다
배포물이나 실행 중 memory에서 사용자가 값을 꺼낼 수 있으면 public client가 되고, server 안에만 두고 응답으로 나가지 않게 할 수 있으면 confidential client다.
native app은 브라우저가 아니지만 배포물을 뜯으면 값이 나오기 때문에 여기서도 public client로 다루게 된다. 실행 환경의 이름이 아니라 값이 어디까지 가는지로 정한다.
### 2. public client에서도 Authorization Code Flow에 PKCE를 함께 쓴다
PKCE는 client secret을 대체하는 client 인증 방식이 아니다. authorization request에서 만든 verifier와 token request의 verifier를 연결해 탈취된 authorization code의 교환을 어렵게 만든다.
여기서 S256을 쓴다. plain은 challenge가 verifier 그대로라서 중간에서 본 사람이 그대로 쓸 수 있다.
### 3. confidential client에도 PKCE를 함께 쓸 수 있다
client 인증이 있어도 PKCE는 여전히 쓸모가 있다. 두 장치가 막는 구간이 서로 달라서 함께 두면 그만큼 좁아지게 된다.
다만 「Authorization Code를 쓴다」와 「PKCE S256까지 설정으로 고정했다」는 서로 다른 주장이다. 설정과 테스트에서 확인한 범위까지만 말할 수 있다.
### 4. public client에서는 implicit flow와 direct access grant를 끈다
implicit flow는 token을 redirect fragment로 받게 되어서 주소창과 히스토리에 token이 남고, direct access grant는 애플리케이션이 사용자의 아이디와 비밀번호를 직접 받게 되어서 IdP만 알면 되는 값을 애플리케이션이 만지게 된다.
현재 예제에서는 Authorization Code Flow를 사용하므로 implicit flow와 direct access grant를 비활성화했다.
### 5. 종류가 곧 브라우저 token 유무는 아니다
confidential client가 code를 교환해도 그 결과인 access token을 응답 본문으로 브라우저에 건넬 수 있고, 실제로 그렇게 도는 구조가 있다.
종류는 secret을 어디에 두는지를 말하고, token 노출은 어느 계층이 API를 부르는지에 따라 갈린다.
## 적용 조건
- 새 OAuth client를 등록할 때
- SPA와 server 중 어디가 code를 교환할지 정할 때
- PKCE와 client 인증을 어디에 둘지 정할 때
- 기존 client의 종류가 맞는지 다시 볼 때
## 예외
- 같은 서비스가 브라우저용 public client와 server용 confidential client를 따로 등록할 수 있다. 하나로 합치려고 secret을 브라우저로 내보내지는 않는다.
- backend가 사용자 없이 자기 자격으로 부르는 흐름은 Client Credentials를 쓰는 별도 client다.
## 예시
- SPA용 client : public, standard flow만 켜고 implicit flow와 direct grant는 끈다
- Mediator용 client : confidential, client_secret_basic으로 token endpoint에서 인증한다
- BFF용 client : confidential, PKCE S256을 함께 쓴다
- Proxy용 client : confidential, oauth2-proxy가 secret과 verifier로 code를 교환한다
- confidential client인 Mediator를 써도 access token은 브라우저 응답에 실릴 수 있다
@@ -0,0 +1,57 @@
{
"kind": "REFERENCE",
"title": "OAuth Token과 Application Session을 구분하는 기준",
"slug": "oauth-token-application-session-boundary",
"summary": "IdP의 SSO session, access token, refresh token, 애플리케이션 session cookie, proxy session cookie는 만든 주체도 소비자도 수명도 다르다. 다섯을 로그인 상태 하나로 부르면 무엇이 만료됐고 무엇을 지워야 하는지 말할 수 없게 된다.",
"purpose": "네 구조를 다 실행해 보면 응답에는 모두 같은 사용자 이름이 나오게 되어서 같은 인증 정보라고 묶기 쉽다.\n\n그런데 값이 들어온 곳을 따라가 보면 어떤 때는 JWT 안의 claim이고 어떤 때는 proxy가 만든 헤더다. 둘을 다 로그인 상태라고 부르게 되면 서명을 검증한 것인지 헤더를 확인한 것인지 문장만 봐서는 구분할 수 없게 된다.\n\n로그아웃과 만료 처리는 credential마다 다르다. 어떤 상태를 삭제하거나 만료시킬지 정하려면 IdP SSO session, OAuth token, application session을 구분해서 다뤄야 한다.",
"rules": [
{
"title": "다섯 상태에 각각 다른 이름을 쓴다",
"body": "IdP SSO session, OAuth access token, OAuth refresh token, 애플리케이션 session cookie, proxy session cookie는 서로 다른 것이라서 문서와 코드, 로그에서 같은 이름을 돌려 쓰지 않는다.\n\n로그와 진단 정보에서도 `로그인 상태`라는 표현만 쓰지 않고 실제 session 또는 token 종류를 기록한다."
},
{
"title": "만든 주체와 주된 소비자로 구분한다",
"body": "access token은 IdP가 만들고 Resource Server가 소비하게 되고, 애플리케이션 session cookie는 애플리케이션이 만들어 자기 로그인 상태를 찾는 데 쓰게 되며, proxy session cookie는 proxy의 auth endpoint에만 제시된다.\n\n화면에 같은 사용자 이름이 보이더라도 credential을 발급한 주체와 검증하는 주체가 다르면 별도의 상태로 다룬다."
},
{
"title": "cookie가 token을 담고 있다고 쓰지 않는다",
"body": "애플리케이션 session cookie는 server-side 상태를 찾는 열쇠다. 실제 access token과 refresh token은 별도 store에 있어서 cookie 안에는 없다.\n\nproxy session cookie는 같은 모델이 아니다. 서버에 상태를 두지 않고 최소 정보를 cookie 자체에 담아 proxy가 검증하는 구성일 수 있다. 두 cookie를 같은 문장으로 설명하지 않는다.\n\ncookie를 token map의 직렬화라고 설명하게 되면 구현 설명이 틀리게 되고, 그 store를 어디에 둘지가 별도 문제라는 것도 함께 가려지게 된다."
},
{
"title": "브라우저에 없다는 말의 대상을 밝힌다",
"body": "브라우저 JavaScript에 OAuth token을 전달하지 않는 구조에서도 인증 상태는 존재한다. BFF의 HttpOnly session cookie나 IdP 도메인의 SSO cookie는 각각 별도로 유지될 수 있다.\n\n무엇이 없는지를 적지 않으면 브라우저에 인증 상태가 아예 없다는 뜻으로 읽힌다."
},
{
"title": "영구 저장소에 없는 것과 실행 중에 없는 것을 나눈다",
"body": "OAuth token을 JavaScript memory에만 보관하면 Web Storage에 지속적으로 저장하지는 않는다. 실행 중 같은 origin의 script가 응답이나 지역 변수에 접근하는 문제는 별도다.\n\n두 문장을 같은 증거로 쓰게 되면 XSS 위험이 줄었다는 잘못된 결론이 나오게 된다."
},
{
"title": "로그아웃 범위를 상태별로 적는다",
"body": "애플리케이션 상태를 지우는 것과 IdP session을 끝내는 것은 다르고, 이미 발급된 self-contained JWT는 만료 전까지 API에서 계속 통하게 된다.\n\nself-contained JWT를 stateless하게 검증하면서 denylist나 introspection을 사용하지 않는 구성에서는 애플리케이션 logout만으로 이미 발급된 access token을 즉시 무효화할 수 없다. 이 경우 짧은 access token TTL을 사용해 유효 시간을 제한한다."
},
{
"title": "하나를 지웠다고 다른 하나가 사라졌다고 쓰지 않는다",
"body": "SPA의 JavaScript memory를 초기화해도 Keycloak SSO session이 유효하면 다음 authorization request에서 다시 인증 화면을 생략할 수 있다.\n\nlogout에서는 application session과 authorized client를 각각 어떻게 정리할지 명시한다."
}
],
"verifiedOn": null,
"applyWhen": [
"인증 상태를 표나 문서로 정리할 때",
"로그아웃과 만료 동작을 설계할 때",
"브라우저에 무엇이 남는지 설명할 때",
"여러 구조를 같은 항목으로 비교할 때"
],
"exceptions": [
"한 요청 안에서 어느 상태를 말하는지 문맥으로 이미 분명하면 짧은 이름을 쓸 수 있다. 그때도 문서에서 처음 나올 때는 전체 이름을 적어 둔다.",
"IdP를 쓰지 않고 애플리케이션이 자체 로그인만 하는 구조에는 SSO session과 access token, refresh token이 없다."
],
"examples": [
"IdP SSO session : IdP 도메인의 cookie이고 애플리케이션 memory와 별개다",
"access token : IdP가 만들고 Resource Server가 서명과 issuer, audience를 검증한다",
"refresh token : 새 access token을 받는 장기 credential이다",
"애플리케이션 session cookie : server-side 로그인 상태를 찾는 열쇠다",
"proxy session cookie : proxy의 auth endpoint에 제시하는 최소 상태다",
"CSRF token : cookie가 자동으로 붙는 상태 변경 요청의 의도를 확인한다",
"identity header : edge가 확인한 사용자 정보의 투영이고 JWT가 아니다"
]
}
@@ -0,0 +1,102 @@
---
id: 66c18e42-116c-459f-86bd-b7e4bf394866
kind: REFERENCE
slug: oauth-token-application-session-boundary
title: OAuth Token과 Application Session을 구분하는 기준
topic: OAuth/OIDC 인증 경계
project: KeyCloak Patterns
status: 게시 전
version: 11
studio: "https://hyeonworks.com/studio/documents/66c18e42-116c-459f-86bd-b7e4bf394866/edit"
---
# OAuth Token과 Application Session을 구분하는 기준
IdP의 SSO session, access token, refresh token, 애플리케이션 session cookie, proxy session cookie는 만든 주체도 소비자도 수명도 다르다. 다섯을 로그인 상태 하나로 부르면 무엇이 만료됐고 무엇을 지워야 하는지 말할 수 없게 된다.
## 관계
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
JavaScript memory의 OAuth token과 Keycloak SSO session을 구분한 Case다.
- **Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조**
같은 요청 안에서 session cookie와 access token이 함께 움직인다.
- **BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정**
BFF에서는 session cookie, JavaScript가 읽는 CSRF token, server-side OAuth token을 각각 다른 용도로 사용한다.
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
Forward-Auth에서는 upstream이 JWT를 직접 검증하지 않고 proxy session을 기반으로 edge가 만든 identity header를 사용한다.
## 목적
네 구조를 다 실행해 보면 응답에는 모두 같은 사용자 이름이 나오게 되어서 같은 인증 정보라고 묶기 쉽다.
그런데 값이 들어온 곳을 따라가 보면 어떤 때는 JWT 안의 claim이고 어떤 때는 proxy가 만든 헤더다. 둘을 다 로그인 상태라고 부르게 되면 서명을 검증한 것인지 헤더를 확인한 것인지 문장만 봐서는 구분할 수 없게 된다.
로그아웃과 만료 처리는 credential마다 다르다. 어떤 상태를 삭제하거나 만료시킬지 정하려면 IdP SSO session, OAuth token, application session을 구분해서 다뤄야 한다.
## 규칙
### 1. 다섯 상태에 각각 다른 이름을 쓴다
IdP SSO session, OAuth access token, OAuth refresh token, 애플리케이션 session cookie, proxy session cookie는 서로 다른 것이라서 문서와 코드, 로그에서 같은 이름을 돌려 쓰지 않는다.
로그와 진단 정보에서도 `로그인 상태`라는 표현만 쓰지 않고 실제 session 또는 token 종류를 기록한다.
### 2. 만든 주체와 주된 소비자로 구분한다
access token은 IdP가 만들고 Resource Server가 소비하게 되고, 애플리케이션 session cookie는 애플리케이션이 만들어 자기 로그인 상태를 찾는 데 쓰게 되며, proxy session cookie는 proxy의 auth endpoint에만 제시된다.
화면에 같은 사용자 이름이 보이더라도 credential을 발급한 주체와 검증하는 주체가 다르면 별도의 상태로 다룬다.
### 3. cookie가 token을 담고 있다고 쓰지 않는다
애플리케이션 session cookie는 server-side 상태를 찾는 열쇠다. 실제 access token과 refresh token은 별도 store에 있어서 cookie 안에는 없다.
proxy session cookie는 같은 모델이 아니다. 서버에 상태를 두지 않고 최소 정보를 cookie 자체에 담아 proxy가 검증하는 구성일 수 있다. 두 cookie를 같은 문장으로 설명하지 않는다.
cookie를 token map의 직렬화라고 설명하게 되면 구현 설명이 틀리게 되고, 그 store를 어디에 둘지가 별도 문제라는 것도 함께 가려지게 된다.
### 4. 브라우저에 없다는 말의 대상을 밝힌다
브라우저 JavaScript에 OAuth token을 전달하지 않는 구조에서도 인증 상태는 존재한다. BFF의 HttpOnly session cookie나 IdP 도메인의 SSO cookie는 각각 별도로 유지될 수 있다.
무엇이 없는지를 적지 않으면 브라우저에 인증 상태가 아예 없다는 뜻으로 읽힌다.
### 5. 영구 저장소에 없는 것과 실행 중에 없는 것을 나눈다
OAuth token을 JavaScript memory에만 보관하면 Web Storage에 지속적으로 저장하지는 않는다. 실행 중 같은 origin의 script가 응답이나 지역 변수에 접근하는 문제는 별도다.
두 문장을 같은 증거로 쓰게 되면 XSS 위험이 줄었다는 잘못된 결론이 나오게 된다.
### 6. 로그아웃 범위를 상태별로 적는다
애플리케이션 상태를 지우는 것과 IdP session을 끝내는 것은 다르고, 이미 발급된 self-contained JWT는 만료 전까지 API에서 계속 통하게 된다.
self-contained JWT를 stateless하게 검증하면서 denylist나 introspection을 사용하지 않는 구성에서는 애플리케이션 logout만으로 이미 발급된 access token을 즉시 무효화할 수 없다. 이 경우 짧은 access token TTL을 사용해 유효 시간을 제한한다.
### 7. Logout 대상 credential을 구체적으로 적는다
SPA의 JavaScript memory를 초기화해도 Keycloak SSO session이 유효하면 다음 authorization request에서 다시 인증 화면을 생략할 수 있다.
logout에서는 application session과 authorized client를 각각 어떻게 정리할지 명시한다.
## 적용 조건
- 인증 상태를 표나 문서로 정리할 때
- 로그아웃과 만료 동작을 설계할 때
- 브라우저가 어떤 credential을 저장하거나 전송하는지 설명할 때
- 여러 구조를 같은 항목으로 비교할 때
## 예외
- 한 요청 안에서 어느 상태를 말하는지 문맥으로 이미 분명하면 짧은 이름을 쓸 수 있다. 그때도 문서에서 처음 나올 때는 전체 이름을 적어 둔다.
- IdP를 쓰지 않고 애플리케이션이 자체 로그인만 하는 구조에는 SSO session과 access token, refresh token이 없다.
## 예시
- IdP SSO session : IdP 도메인의 cookie이고 애플리케이션 memory와 별개다
- access token : IdP가 만들고 Resource Server가 서명과 issuer, audience를 검증한다
- refresh token : 새 access token을 받는 장기 credential이다
- 애플리케이션 session cookie : server-side 로그인 상태를 찾는 열쇠다
- proxy session cookie : proxy의 auth endpoint에 제시하는 최소 상태다
- CSRF token : cookie가 자동으로 붙는 상태 변경 요청의 의도를 확인한다
- identity header : edge가 확인한 사용자 정보의 투영이고 JWT가 아니다
@@ -0,0 +1,501 @@
[
{
"file": "case-ap2-split-custody.md",
"id": "488ce49b-afa4-42a5-a2ce-de2e0653cd82",
"kind": "CASE",
"title": "Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조",
"relations": [
{
"kind": "관계",
"target": "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계",
"reason": "SPA에서는 브라우저가 code 교환과 token 보관을 직접 수행한다. 이 Case에서는 code 교환과 refresh token 보관을 mediator가 수행하도록 구성했다."
},
{
"kind": "관계",
"target": "Public Client와 Confidential Client 구분 기준",
"reason": "confidential client를 쓰면서도 access token이 브라우저 응답에 실린다. 종류와 token 노출이 별개라는 근거다."
},
{
"kind": "관계",
"target": "OAuth Token과 Application Session을 구분하는 기준",
"reason": "access token 원문이 응답 본문과 지역 변수와 헤더를 지난다. 상태별 이름을 나눠야 하는 이유다."
},
{
"kind": "관계",
"target": "OAuth/OIDC 인증 패턴 선택 기준",
"reason": "mediator가 refresh token을 관리하면서도 브라우저가 Resource Server를 직접 호출하는 구성을 비교할 때 사용하는 Case다."
},
{
"kind": "관계",
"target": "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가",
"reason": "refresh token rotation과 재사용 0회를 쓰는 구성이다. replica 경쟁 질문의 전제다."
}
]
},
{
"file": "case-ap3-bff-session-csrf.md",
"id": "d85bd6af-7599-4ef7-9407-6609927d5b5c",
"kind": "CASE",
"title": "BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정",
"relations": [
{
"kind": "관계",
"target": "BFF 인증 구조 설계 기준",
"reason": "이 기준이 요구하는 항목 중 무엇이 구현됐고 무엇이 구현되지 않았는지"
},
{
"kind": "관계",
"target": "OAuth Token과 Application Session을 구분하는 기준",
"reason": "session cookie와 CSRF token, server-side token을 각각 다뤄야 하는 이유"
},
{
"kind": "관계",
"target": "OAuth/OIDC 인증 패턴 선택 기준",
"reason": "BFF 구조에서 필요한 CSRF 검증과 server-side 상태 저장 기준을 함께 다룬다"
},
{
"kind": "관계",
"target": "BFF가 OAuth Token을 관리하는 조건",
"reason": "이 결정의 구조를 실제로 실행해 본 문서"
},
{
"kind": "관계",
"target": "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가",
"reason": "두 상태가 모두 process-local memory에 있다는 점이 질문의 시작이다"
},
{
"kind": "관계",
"target": "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가",
"reason": "session과 authorized client의 2가지 흐름"
}
]
},
{
"file": "case-ap4-identity-header-trust.md",
"id": "a0e1cc05-92b3-4dac-bce1-513ab8cd862b",
"kind": "CASE",
"title": "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유",
"relations": [
{
"kind": "관계",
"target": "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건",
"reason": "identity header를 신뢰하기 위한 조건을 Nginx, oauth2-proxy, backend 설정과 요청 결과로 확인했다."
},
{
"kind": "관계",
"target": "OAuth Token과 Application Session을 구분하는 기준",
"reason": "Forward-Auth에서는 proxy session cookie와 identity header를 JWT와 구분해 다룬다."
},
{
"kind": "관계",
"target": "OAuth/OIDC 인증 패턴 선택 기준",
"reason": "OAuth 처리는 edge에서 끝내고 upstream은 검증된 identity header를 사용하도록 구성한 Case다."
},
{
"kind": "관계",
"target": "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가",
"reason": "edge가 user와 email만 전달한다는 사실이 이 질문의 출발점이다."
}
]
},
{
"file": "case-browser-credential-boundary.md",
"id": "bf675775-4f3e-4744-8014-f0efff51422a",
"kind": "CASE",
"title": "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계",
"relations": [
{
"kind": "관계",
"target": "Authorization Code Flow의 Endpoint와 Credential 이동 기준",
"reason": "브라우저가 authorization endpoint와 token endpoint를 직접 호출하는 흐름을 코드와 network 요청으로 확인했다."
},
{
"kind": "관계",
"target": "Public Client와 Confidential Client 구분 기준",
"reason": "SPA는 client secret을 안전하게 보관할 수 없어 public client로 등록했고, Authorization Code Flow에는 PKCE를 적용했다."
},
{
"kind": "관계",
"target": "OAuth Token과 Application Session을 구분하는 기준",
"reason": "JavaScript memory의 OAuth token과 Keycloak 도메인의 SSO cookie가 서로 다른 상태라는 점을 확인했다."
},
{
"kind": "관계",
"target": "인증 구조를 보안 성숙도 단계로 취급하지 않는다",
"reason": "이 Case의 SPA 구성을 다른 패턴보다 낮은 단계로 해석하지 않도록 별도의 결정 기록에서 기준을 정했다."
}
]
},
{
"file": "decision-bff-owns-token.md",
"id": "19b55c39-c583-4161-9775-df954280a568",
"kind": "PROJECT_DECISION",
"title": "BFF가 OAuth Token을 관리하는 조건",
"relations": [
{
"kind": "근거",
"target": "BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정",
"reason": "이 결정이 가리키는 구조를 실제로 실행해 본 기록이다."
},
{
"kind": "근거",
"target": "BFF 인증 구조 설계 기준",
"reason": "이 결정이 PROPOSED인 동안의 실제 적용 기준이다."
},
{
"kind": "근거",
"target": "OAuth/OIDC 인증 패턴 선택 기준",
"reason": "이 결정을 적용할 조건과 피해야 할 조건이 여기 있다."
},
{
"kind": "근거",
"target": "Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조",
"reason": "access token이 브라우저로 나가 이 요구를 만족하지 못한 경우다."
}
]
},
{
"file": "decision-federation-not-a-pattern.md",
"id": "8c1ebea7-204e-445c-9812-0421d9eb0e9c",
"kind": "PROJECT_DECISION",
"title": "외부 IdP Federation을 별도의 인증 구조로 세지 않는다",
"relations": [
{
"kind": "근거",
"target": "외부 IdP Federation과 Application 인증 경계",
"reason": "이 결정을 규칙으로 편 기준이다."
},
{
"kind": "근거",
"target": "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계",
"reason": "브로커가 발급한 code를 받는 애플리케이션 경계다."
},
{
"kind": "근거",
"target": "OAuth Token과 Application Session을 구분하는 기준",
"reason": "upstream IdP 상태와 애플리케이션 상태를 같은 이름으로 부르지 않는다."
}
]
},
{
"file": "decision-not-maturity-ladder.md",
"id": "5f4b6000-cb78-400c-bf6e-a25632a4bb40",
"kind": "PROJECT_DECISION",
"title": "인증 구조를 보안 성숙도 단계로 취급하지 않는다",
"relations": [
{
"kind": "근거",
"target": "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계",
"reason": "브라우저가 code 교환, token 보관, API 호출을 직접 수행한다."
},
{
"kind": "근거",
"target": "Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조",
"reason": "mediator가 code 교환과 refresh token 보관을 담당하고 브라우저가 access token으로 API를 직접 호출한다."
},
{
"kind": "근거",
"target": "BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정",
"reason": "BFF가 token과 session을 server-side에서 관리하고 Resource Server를 호출한다."
},
{
"kind": "근거",
"target": "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유",
"reason": "oauth2-proxy가 인증을 처리하고 upstream에는 identity header를 전달한다."
},
{
"kind": "근거",
"target": "OAuth/OIDC 인증 패턴 선택 기준",
"reason": "이 결정을 적용하는 선택 기준이다."
}
]
},
{
"file": "question-bff-state-store.md",
"id": "18a5cde2-dd1e-4bff-9f1c-997577ae438f",
"kind": "QUESTION",
"title": "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가",
"relations": [
{
"kind": "관계",
"target": "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가",
"reason": "이 질문에서 저장소 부분만 떼어 낸 것이다."
},
{
"kind": "관계",
"target": "BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정",
"reason": "session과 authorized client의 열쇠가 다르다는 사실의 출처다."
},
{
"kind": "관계",
"target": "BFF 인증 구조 설계 기준",
"reason": "이 기준의 저장소 항목이 이 질문의 답을 기다린다."
},
{
"kind": "관계",
"target": "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가",
"reason": "저장소를 공유한 뒤에야 replica 경쟁이 재현된다."
}
]
},
{
"file": "question-edge-authorization-scope.md",
"id": "7ff40767-a00b-4db2-98f6-0cdfce8c8936",
"kind": "QUESTION",
"title": "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가",
"relations": [
{
"kind": "관계",
"target": "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유",
"reason": "edge가 user와 email만 전달한다는 사실의 출처다."
},
{
"kind": "관계",
"target": "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건",
"reason": "헤더 allowlist와 검증 조건이 이 기준에 있다."
},
{
"kind": "관계",
"target": "BFF 인증 구조 설계 기준",
"reason": "되돌리는 선택지의 기준이 이 문서다."
}
]
},
{
"file": "question-multi-instance-session.md",
"id": "c72656b5-842d-45d9-b5f6-82b66b09d0b9",
"kind": "QUESTION",
"title": "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가",
"relations": [
{
"kind": "관계",
"target": "BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정",
"reason": "두 상태가 모두 process-local memory에 있다는 사실의 출처다."
},
{
"kind": "관계",
"target": "Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조",
"reason": "같은 저장소 구성을 쓰는 다른 패턴이다."
},
{
"kind": "관계",
"target": "BFF 인증 구조 설계 기준",
"reason": "이 질문의 답이 이 기준의 빈 항목을 채운다."
},
{
"kind": "관계",
"target": "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가",
"reason": "저장소 후보 비교로 독립시킨 질문이다."
}
]
},
{
"file": "question-refresh-rotation-replica.md",
"id": "9ae4ec71-a32e-49a7-88c2-f7368541c28d",
"kind": "QUESTION",
"title": "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가",
"relations": [
{
"kind": "관계",
"target": "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가",
"reason": "저장소 결정이 이 질문보다 앞선다."
},
{
"kind": "관계",
"target": "Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조",
"reason": "rotation과 재사용 0회를 쓰는 구성의 출처다."
},
{
"kind": "관계",
"target": "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가",
"reason": "다중 인스턴스 운영이 이 경쟁의 전제다."
},
{
"kind": "관계",
"target": "BFF 인증 구조 설계 기준",
"reason": "갱신 실패를 화면 오류로 바꾸는 규칙이 이 기준의 항목이다."
}
]
},
{
"file": "reference-authorization-code-endpoints.md",
"id": "39fdf472-82c4-43ed-abec-73de672f08ae",
"kind": "REFERENCE",
"title": "Authorization Code Flow의 Endpoint와 Credential 이동 기준",
"relations": [
{
"kind": "관계",
"target": "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계",
"reason": "브라우저가 code를 직접 교환하는 흐름에서 endpoint별 이동을 관측했다."
},
{
"kind": "관계",
"target": "Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조",
"reason": "confidential client가 token endpoint에서 client 인증을 수행하는 흐름을 보여 준다."
},
{
"kind": "관계",
"target": "Public Client와 Confidential Client 구분 기준",
"reason": "public/confidential client 구분에 따라 token endpoint의 client 인증 방식이 달라지고, Authorization Code Flow에서는 PKCE 적용 여부도 함께 결정한다."
}
]
},
{
"file": "reference-bff-auth-design.md",
"id": "97eddd97-1096-426a-a2c6-a6c5bf1cd09f",
"kind": "REFERENCE",
"title": "BFF 인증 구조 설계 기준",
"relations": [
{
"kind": "관계",
"target": "BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정",
"reason": "이 기준의 항목 중 실제로 구현된 것과 비어 있는 것을 센 기록이다."
},
{
"kind": "관계",
"target": "서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가",
"reason": "저장소 항목이 아직 답이 없는 질문으로 남아 있다."
},
{
"kind": "관계",
"target": "BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가",
"reason": "어느 저장소에 둘지가 이 기준의 미결 항목이다."
},
{
"kind": "관계",
"target": "BFF가 OAuth Token을 관리하는 조건",
"reason": "이 결정이 PROPOSED인 동안 실제 적용 기준은 이 문서다."
}
]
},
{
"file": "reference-forward-auth-header-trust.md",
"id": "004dd0a2-5fb3-4f25-80c9-576f709de331",
"kind": "REFERENCE",
"title": "Forward-Auth에서 Identity Header를 신뢰하기 위한 조건",
"relations": [
{
"kind": "관계",
"target": "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유",
"reason": "이 기준의 다섯 조건을 실제 설정에서 확인한 기록이다."
},
{
"kind": "관계",
"target": "Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가",
"reason": "헤더를 어디까지 늘릴지가 이 기준의 미결 항목이다."
},
{
"kind": "관계",
"target": "OAuth Token과 Application Session을 구분하는 기준",
"reason": "identity 헤더를 JWT나 session과 같은 이름으로 부르지 않는다."
}
]
},
{
"file": "reference-idp-federation-boundary.md",
"id": "1a00a640-8987-4075-a9e4-7ec023cdffbb",
"kind": "REFERENCE",
"title": "외부 IdP Federation과 Application 인증 경계",
"relations": [
{
"kind": "관계",
"target": "외부 IdP Federation을 별도의 인증 구조로 세지 않는다",
"reason": "이 기준을 프로젝트 결정으로 굳힌 기록이다."
},
{
"kind": "관계",
"target": "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계",
"reason": "브로커가 만든 authorization code를 애플리케이션이 받는 흐름이다."
},
{
"kind": "관계",
"target": "Authorization Code Flow의 Endpoint와 Credential 이동 기준",
"reason": "외부 IdP가 있어도 애플리케이션 쪽 endpoint 이동은 그대로다."
}
]
},
{
"file": "reference-pattern-selection.md",
"id": "3f886154-1b85-407b-bda4-57d28370e745",
"kind": "REFERENCE",
"title": "OAuth/OIDC 인증 패턴 선택 기준",
"relations": [
{
"kind": "관계",
"target": "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계",
"reason": "브라우저가 code 교환과 token 보관, API 호출을 모두 맡는다."
},
{
"kind": "관계",
"target": "Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조",
"reason": "mediator가 refresh token을 관리하고 브라우저가 access token으로 API를 직접 호출하는 구성을 확인했다."
},
{
"kind": "관계",
"target": "BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정",
"reason": "BFF가 code 교환, token 보관, Resource Server 호출을 모두 처리하는 구성을 확인했다."
},
{
"kind": "관계",
"target": "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유",
"reason": "인증이 edge로 가면 보호 자원이 검증하는 것이 JWT에서 헤더로 바뀐다."
},
{
"kind": "관계",
"target": "인증 구조를 보안 성숙도 단계로 취급하지 않는다",
"reason": "이 기준의 첫 항목을 프로젝트 결정으로 굳힌 기록이다."
}
]
},
{
"file": "reference-public-confidential-client.md",
"id": "ede6b9ce-eeed-40c8-9175-9e8116029395",
"kind": "REFERENCE",
"title": "Public Client와 Confidential Client 구분 기준",
"relations": [
{
"kind": "관계",
"target": "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계",
"reason": "SPA를 public client로 등록한 이유를 실제 구성에서 확인할 수 있다."
},
{
"kind": "관계",
"target": "Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조",
"reason": "confidential client를 사용해도 access token 전달 방식은 별도로 설계된다는 예다."
},
{
"kind": "관계",
"target": "Authorization Code Flow의 Endpoint와 Credential 이동 기준",
"reason": "client 종류에 따라 token endpoint의 client 인증 방식이 달라진다."
}
]
},
{
"file": "reference-token-vs-session.md",
"id": "66c18e42-116c-459f-86bd-b7e4bf394866",
"kind": "REFERENCE",
"title": "OAuth Token과 Application Session을 구분하는 기준",
"relations": [
{
"kind": "관계",
"target": "SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계",
"reason": "JavaScript memory의 OAuth token과 Keycloak SSO session을 구분한 Case다."
},
{
"kind": "관계",
"target": "Mediator가 Refresh Token을 관리하고 Access Token을 Browser에 전달하는 구조",
"reason": "같은 요청 안에서 session cookie와 access token이 함께 움직인다."
},
{
"kind": "관계",
"target": "BFF에서 OAuth Token을 관리할 때 Session과 CSRF를 처리한 과정",
"reason": "BFF에서는 session cookie, JavaScript가 읽는 CSRF token, server-side OAuth token을 각각 다른 용도로 사용한다."
},
{
"kind": "관계",
"target": "Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유",
"reason": "Forward-Auth에서는 upstream이 JWT를 직접 검증하지 않고 proxy session을 기반으로 edge가 만든 identity header를 사용한다."
}
]
}
]