feat: 가상화 문서들 추가

This commit is contained in:
DongHyeonka
2026-09-10 08:54:05 +09:00
parent e9f6a93327
commit 43e1aadef0
695 changed files with 153404 additions and 12754 deletions
@@ -11,7 +11,7 @@
<mxCell id="n_api-resource-server" value="Resource Server&lt;br/&gt;issuer · time&lt;br/&gt;keycloak-pattern-api audience" tooltip="Access JWT의 issuer, 시간 제약과 audience를 검증하는 API. | Evidence: L144-L150" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="380.0" y="60.0" width="237.0" height="88.0" as="geometry"/>
</mxCell>
<mxCell id="n_browser-token-memory" value="Browser JS memory&lt;br/&gt;access · refresh · ID token&lt;br/&gt;persistent Web Storage 없음" tooltip="Access, refresh, ID token을 실행 중 보관하며 reload 뒤에는 복구하지 않는 browser-side custody. | Evidence: L148-L150" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;shape=cylinder3;boundedLbl=1;backgroundOutline=1;fillColor=#eef6fb;" vertex="1" parent="1">
<mxCell id="n_browser-token-memory" value="Browser JS memory&lt;br/&gt;access · refresh · ID token&lt;br/&gt;persistent Web Storage 없음" tooltip="Access, refresh, ID token을 실행 중 보관하며 reload 뒤에는 복구하지 않는 browser-side custody. | Evidence: L148-L150" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="387.0" y="220.0" width="223.0" height="88.0" as="geometry"/>
</mxCell>
<mxCell id="n_keycloak" value="Keycloak" tooltip="SPA의 code 교환 상대이며 API가 검증하는 JWT 서명의 출처. | Evidence: L144-L146" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
@@ -2,7 +2,7 @@
"harness_version": "0.2.0",
"spec_id": "ap1-direct-architecture",
"spec_version": "1.1",
"spec_sha256": "71f2cf1931f3be6908cf13db36577eb61cba8d4cdc863bd6057a8c9093fd0bcb",
"spec_sha256": "ea8f3f861258a68eabe2e902fb8a5b85ce250a68ef5893eefa8701f1b8eb3627",
"source_context": {
"document": "document.md",
"document_sha256": "df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371",
@@ -77,7 +77,7 @@
<text class="node-detail" x="396.0" y="141.0">keycloak-pattern-api audience</text>
</g>
<g id="node-browser-token-memory">
<rect class="node-shape kind-database emphasis-normal role-store" data-evidence="148-150" x="387.0" y="233.0" width="223.0" height="62.0" /><ellipse class="node-shape kind-database emphasis-normal role-store" cx="498.5" cy="233.0" rx="111.5" ry="13.0" /><path class="storage-bottom" d="M 387.0 295.0 A 111.5 13.0 0 0 0 610.0 295.0" />
<rect class="node-shape kind-database emphasis-normal role-store" data-evidence="148-150" x="387.0" y="220.0" width="223.0" height="88.0" rx="7" />
<text class="node-label" x="498.5" y="247.0">Browser JS memory</text>
<line class="node-detail-divider" x1="401.0" y1="268.0" x2="596.0" y2="268.0" />
<text class="node-detail" x="403.0" y="285.0">access · refresh · ID token</text>

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

@@ -20,4 +20,4 @@
- **Browser → Resource Server:** Bearer header. Evidence: L158L160.
- **Browser → Spring mediator:** AP2_SESSION. Evidence: L158L160.
- **Spring mediator → Browser:** access_token · type · expiry. Evidence: L158L158.
- **Spring mediator → Authorized-client store:** 보관 · 현재 access 조회. Evidence: L156L160.
- **Spring mediator → Authorized-client store:** 보관 · 조회. Evidence: L156L160.
@@ -14,6 +14,6 @@ n3: "Resource Server" {
shape: rectangle
}
n0 -> n1: "AP2_SESSION"
n1 -> n2: "보관 · 현재 access 조회"
n1 -> n2: "보관 · 조회"
n1 -> n0: "access_token · type · expiry"
n0 -> n3: "Bearer header"
@@ -7,7 +7,7 @@ digraph techviz {
n2 [label="Authorized-client store", shape=cylinder, style="rounded,filled"];
n3 [label="Resource Server", shape=box, style="rounded,filled"];
n0 -> n1 [label="AP2_SESSION", style=solid];
n1 -> n2 [label="보관 · 현재 access 조회", style=solid];
n1 -> n2 [label="보관 · 조회", style=solid];
n1 -> n0 [label="access_token · type · expiry", style=solid];
n0 -> n3 [label="Bearer header", style=solid];
}
@@ -14,7 +14,7 @@
<mxCell id="n_mediator" value="Spring mediator&lt;br/&gt;oauth2Login&lt;br/&gt;confidential client" tooltip="Confidential OAuth client로 code를 교환하고 browser에 access-only 응답을 제공한다. | Evidence: L156-L158" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="400.0" y="196.0" width="167.0" height="88.0" as="geometry"/>
</mxCell>
<mxCell id="n_authorized-client-store" value="Authorized-client store&lt;br/&gt;access token&lt;br/&gt;refresh token" tooltip="Access와 refresh token을 server-side state로 보관한다. | Evidence: L156-L160" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;shape=cylinder3;boundedLbl=1;backgroundOutline=1;fillColor=#eef6fb;" vertex="1" parent="1">
<mxCell id="n_authorized-client-store" value="Authorized-client store&lt;br/&gt;access token&lt;br/&gt;refresh token" tooltip="Access와 refresh token을 server-side state로 보관한다. | Evidence: L156-L160" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="727.0" y="118.0" width="153.0" height="108.0" as="geometry"/>
</mxCell>
<mxCell id="e_browser-to-api" value="Bearer header" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_browser" target="n_api-resource-server">
@@ -32,7 +32,7 @@
<mxPoint x="297.2" y="376.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_mediator-to-store" value="보관 · 현재 access 조회" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_mediator" target="n_authorized-client-store">
<mxCell id="e_mediator-to-store" value="보관 · 조회" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_mediator" target="n_authorized-client-store">
<mxGeometry relative="1" as="geometry">
<mxPoint x="671.0" y="206.0" as="offset"/>
</mxGeometry>
@@ -363,9 +363,9 @@
{
"id": "edge-label-mediator-to-store",
"type": "text",
"x": 603.0,
"x": 626.0,
"y": 194.0,
"width": 136,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -391,11 +391,11 @@
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "보관 · 현재 access 조회",
"text": "보관 · 조회",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "보관 · 현재 access 조회",
"originalText": "보관 · 조회",
"autoResize": true,
"lineHeight": 1.25
},
@@ -2,7 +2,7 @@
"harness_version": "0.2.0",
"spec_id": "ap2-mediator-architecture",
"spec_version": "1.1",
"spec_sha256": "00183e9d9c71bd0743bb35922fe89898c7cce05f9b13492d1b38fe98be41ba1d",
"spec_sha256": "57354aea377e1c3f11e19fd03ccffba9df44f37b78a5fa69f8b2521705f8f6d7",
"source_context": {
"document": "document.md",
"document_sha256": "df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371",
@@ -6,7 +6,7 @@ flowchart LR
n2[("Authorized-client store")]
n3["Resource Server"]
n0 -->|"AP2_SESSION"| n1
n1 -->|"보관 · 현재 access 조회"| n2
n1 -->|"보관 · 조회"| n2
n1 -->|"access_token · type · expiry"| n0
n0 -->|"Bearer header"| n3
classDef external stroke-dasharray: 6 4
@@ -60,10 +60,10 @@
<rect class="edge-label-bg" x="194.4" y="362.0" width="205.6" height="22" rx="3" />
<text class="edge-label" x="297.2" y="377.0">access_token · type · expiry</text>
<polyline class="edge kind-data style-solid emphasis-normal" points="567.0,240.0 647.0,240.0 647.0,172.0 727.0,172.0" data-evidence="156-160" />
<rect class="edge-label-bg" x="605.0" y="192.0" width="131.9" height="22" rx="3" />
<text class="edge-label" x="671.0" y="207.0">보관 · 현재 access 조회</text>
<rect class="edge-label-bg" x="638.5" y="192.0" width="64.9" height="22" rx="3" />
<text class="edge-label" x="671.0" y="207.0">보관 · 조회</text>
<g id="node-browser">
<g class="actor-symbol emphasis-normal" data-evidence="156-160"><circle cx="155.0" cy="148.0" r="11.0" /><line x1="155.0" y1="164.0" x2="155.0" y2="187.0" /><line x1="137.0" y1="174.0" x2="173.0" y2="174.0" /><line x1="155.0" y1="187.0" x2="140.0" y2="204.0" /><line x1="155.0" y1="187.0" x2="170.0" y2="204.0" /></g>
<g class="actor-symbol emphasis-normal" data-evidence="156-160"><circle cx="155.0" cy="148.0" r="11.0" /><line x1="155.0" y1="164.0" x2="155.0" y2="177.0" /><line x1="137.0" y1="174.0" x2="173.0" y2="174.0" /><line x1="155.0" y1="177.0" x2="140.0" y2="194.0" /><line x1="155.0" y1="177.0" x2="170.0" y2="194.0" /></g>
<text class="node-label" x="155.0" y="209.0">Browser</text>
</g>
<g id="node-api-resource-server">
@@ -78,7 +78,7 @@
<text class="node-detail" x="416.0" y="277.0">confidential client</text>
</g>
<g id="node-authorized-client-store">
<rect class="node-shape kind-database emphasis-normal role-store" data-evidence="156-160" x="727.0" y="131.0" width="153.0" height="82.0" /><ellipse class="node-shape kind-database emphasis-normal role-store" cx="803.5" cy="131.0" rx="76.5" ry="13.0" /><path class="storage-bottom" d="M 727.0 213.0 A 76.5 13.0 0 0 0 880.0 213.0" />
<rect class="node-shape kind-database emphasis-normal role-store" data-evidence="156-160" x="727.0" y="118.0" width="153.0" height="108.0" rx="7" />
<text class="node-label" x="803.5" y="145.0">Authorized-client</text>
<text class="node-label" x="803.5" y="163.0">store</text>
<line class="node-detail-divider" x1="741.0" y1="184.0" x2="866.0" y2="184.0" />

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

@@ -10,15 +10,15 @@ Browser session zone과 server-side BFF zone 사이에서 AP3_SESSION이 downstr
## Elements and evidence
- **Boundary: Browser session boundary** (system): OAuth token 대신 HttpOnly AP3_SESSION을 보유하는 browser 경계. Evidence: L166L168.
- **Boundary: Server-side application path** (system): BFF, server-side authorized client와 내부 Resource Server가 연결되는 backend 경계. Evidence: L166L170.
- **Browser** (actor): OAuth token을 받지 않고 AP3_SESSION으로 BFF endpoint를 호출한다. Evidence: L166L168.
- **Spring BFF** (service): 브라우저 session을 현재 authorized client와 downstream Bearer 요청으로 변환하는 confidential client. Evidence: L166L170.
- **Authorized-client store** (database): BFF가 access와 refresh token을 보관하고 현재 authorized client를 조회하는 server-side state. Evidence: L166L170.
- **Internal Resource Server** (service): BFF가 만든 Bearer 요청을 받는 내부 보호 자원. Evidence: L168L168.
- **Boundary: Browser session boundary** (system): OAuth token 대신 HttpOnly AP3_SESSION을 보유하는 browser 경계. Evidence: L192L194.
- **Boundary: Server-side application path** (system): BFF, server-side authorized client와 내부 Resource Server가 연결되는 backend 경계. Evidence: L192L196.
- **Browser** (actor): OAuth token을 받지 않고 AP3_SESSION으로 BFF endpoint를 호출한다. Evidence: L192L194.
- **Spring BFF** (service): 브라우저 session을 현재 authorized client와 downstream Bearer 요청으로 변환하는 confidential client. Evidence: L192L196.
- **Authorized-client store** (database): BFF가 access와 refresh token을 보관하고 현재 authorized client를 조회하는 server-side state. Evidence: L192L196.
- **Internal Resource Server** (service): BFF가 만든 Bearer 요청을 받는 내부 보호 자원. Evidence: L194L194.
## Relationships
- **Spring BFF → Internal Resource Server:** downstream Bearer 요청. Evidence: L168L168.
- **Spring BFF → Authorized-client store:** 현재 authorized client 조회. Evidence: L168L168.
- **Browser → Spring BFF:** AP3_SESSION · /bff/api/me. Evidence: L168L168.
- **Spring BFF → Internal Resource Server:** Bearer 요청. Evidence: L194L194.
- **Spring BFF → Authorized-client store:** client 조회. Evidence: L194L194.
- **Browser → Spring BFF:** AP3_SESSION. Evidence: L194L194.
@@ -17,6 +17,6 @@ g1: "Server-side application path" {
shape: rectangle
}
}
g0.n0 -> g1.n1: "AP3_SESSION · /bff/api/me"
g1.n1 -> g1.n2: "현재 authorized client 조회"
g1.n1 -> g1.n3: "downstream Bearer 요청"
g0.n0 -> g1.n1: "AP3_SESSION"
g1.n1 -> g1.n2: "client 조회"
g1.n1 -> g1.n3: "Bearer 요청"
@@ -16,7 +16,7 @@ digraph techviz {
n2 [label="Authorized-client store", shape=cylinder, style="rounded,filled"];
n3 [label="Internal Resource Server", shape=box, style="rounded,filled"];
}
n0 -> n1 [label="AP3_SESSION · /bff/api/me", style=solid];
n1 -> n2 [label="현재 authorized client 조회", style=solid];
n1 -> n3 [label="downstream Bearer 요청", style=solid];
n0 -> n1 [label="AP3_SESSION", style=solid];
n1 -> n2 [label="client 조회", style=solid];
n1 -> n3 [label="Bearer 요청", style=solid];
}
@@ -1,41 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
<diagram id="ap3-bff-architecture" name="AP3는 browser session을 BFF-owned Bearer 요청으로 바꾼다">
<mxGraphModel dx="1080" dy="385" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1080" pageHeight="1169" math="0" shadow="0">
<mxGraphModel dx="938" dy="406" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="938" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="g_browser-session-zone" value="Browser session boundary" style="swimlane;html=1;rounded=1;startSize=30;horizontal=1;dashed=1;strokeWidth=1.5;fillColor=#f7f9fb;strokeColor=#66788a;fontStyle=1;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="45.0" y="49.0" width="250.0" height="160.0" as="geometry"/>
<mxGeometry x="40.0" y="118.0" width="230.0" height="160.0" as="geometry"/>
</mxCell>
<mxCell id="g_server-application-zone" value="Server-side application path" style="swimlane;html=1;rounded=1;startSize=30;horizontal=1;dashed=1;strokeWidth=1.5;fillColor=#f7f9fb;strokeColor=#66788a;fontStyle=1;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="565.0" y="49.0" width="470.0" height="291.0" as="geometry"/>
<mxGeometry x="370.0" y="35.0" width="523.0" height="326.0" as="geometry"/>
</mxCell>
<mxCell id="n_browser" value="Browser&lt;br/&gt;HttpOnly AP3_SESSION&lt;br/&gt;OAuth token 없음" tooltip="OAuth token을 받지 않고 AP3_SESSION으로 BFF endpoint를 호출한다. | Evidence: L166-L168" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;dashed=1;fillColor=#f5f7fa;" vertex="1" parent="1">
<mxGeometry x="75.0" y="95.0" width="190.0" height="88.0" as="geometry"/>
<mxCell id="n_browser" value="Browser&lt;br/&gt;HttpOnly AP3_SESSION&lt;br/&gt;OAuth token 없음" tooltip="OAuth token을 받지 않고 AP3_SESSION으로 BFF endpoint를 호출한다. | Evidence: L192-L194" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;dashed=1;fillColor=#f5f7fa;" vertex="1" parent="1">
<mxGeometry x="70.0" y="164.0" width="170.0" height="88.0" as="geometry"/>
</mxCell>
<mxCell id="n_bff" value="Spring BFF&lt;br/&gt;bff-confidential&lt;br/&gt;oauth2Login" tooltip="브라우저 session을 현재 authorized client와 downstream Bearer 요청으로 변환하는 confidential client. | Evidence: L166-L170" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="595.0" y="95.0" width="190.0" height="88.0" as="geometry"/>
<mxCell id="n_bff" value="Spring BFF&lt;br/&gt;bff-confidential&lt;br/&gt;oauth2Login&lt;br/&gt;/bff/api/me" tooltip="브라우저 session을 현재 authorized client와 downstream Bearer 요청으로 변환하는 confidential client. | Evidence: L192-L196" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="400.0" y="155.5" width="150.0" height="105.0" as="geometry"/>
</mxCell>
<mxCell id="n_authorized-client-store" value="Authorized-client store&lt;br/&gt;access token&lt;br/&gt;refresh token" tooltip="BFF가 access와 refresh token을 보관하고 현재 authorized client를 조회하는 server-side state. | Evidence: L166-L170" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;shape=cylinder3;boundedLbl=1;backgroundOutline=1;fillColor=#eef6fb;" vertex="1" parent="1">
<mxGeometry x="815.0" y="95.0" width="190.0" height="108.0" as="geometry"/>
<mxCell id="n_authorized-client-store" value="Authorized-client store&lt;br/&gt;access token&lt;br/&gt;refresh token" tooltip="BFF가 access와 refresh token을 보관하고 현재 authorized client를 조회하는 server-side state. | Evidence: L192-L196" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="710.0" y="81.0" width="153.0" height="108.0" as="geometry"/>
</mxCell>
<mxCell id="n_resource-server" value="Internal Resource Server" tooltip="BFF가 만든 Bearer 요청을 받는 내부 보호 자원. | Evidence: L168-L168" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="595.0" y="240.0" width="190.0" height="74.0" as="geometry"/>
<mxCell id="n_resource-server" value="Internal Resource Server" tooltip="BFF가 만든 Bearer 요청을 받는 내부 보호 자원. | Evidence: L194-L194" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="710.0" y="261.0" width="153.0" height="74.0" as="geometry"/>
</mxCell>
<mxCell id="e_bff-to-resource-server" value="downstream Bearer 요청" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_bff" target="n_resource-server">
<mxCell id="e_bff-to-resource-server" value="Bearer 요청" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_bff" target="n_resource-server">
<mxGeometry relative="1" as="geometry">
<mxPoint x="714.0" y="212.5" as="offset"/>
<mxPoint x="654.0" y="257.5" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_bff-to-store" value="현재 authorized client 조회" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_bff" target="n_authorized-client-store">
<mxCell id="e_bff-to-store" value="client 조회" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_bff" target="n_authorized-client-store">
<mxGeometry relative="1" as="geometry">
<mxPoint x="790.5" y="31.0" as="offset"/>
<mxPoint x="654.0" y="167.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_browser-to-bff" value="AP3_SESSION · /bff/api/me" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_browser" target="n_bff">
<mxCell id="e_browser-to-bff" value="AP3_SESSION" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_browser" target="n_bff">
<mxGeometry relative="1" as="geometry">
<mxPoint x="430.0" y="111.0" as="offset"/>
<mxPoint x="320.0" y="180.0" as="offset"/>
</mxGeometry>
</mxCell>
</root>
@@ -6,9 +6,9 @@
{
"id": "group-browser-session-zone",
"type": "rectangle",
"x": 45.0,
"y": 49.0,
"width": 250.0,
"x": 40.0,
"y": 118.0,
"width": 230.0,
"height": 160.0,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -36,8 +36,8 @@
{
"id": "group-label-browser-session-zone",
"type": "text",
"x": 61.0,
"y": 55.0,
"x": 56.0,
"y": 124.0,
"width": 216,
"height": 24,
"angle": 0,
@@ -75,10 +75,10 @@
{
"id": "group-server-application-zone",
"type": "rectangle",
"x": 565.0,
"y": 49.0,
"width": 470.0,
"height": 291.0,
"x": 370.0,
"y": 35.0,
"width": 523.0,
"height": 326.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#f8f9fa",
@@ -105,8 +105,8 @@
{
"id": "group-label-server-application-zone",
"type": "text",
"x": 581.0,
"y": 55.0,
"x": 386.0,
"y": 41.0,
"width": 252,
"height": 24,
"angle": 0,
@@ -144,10 +144,10 @@
{
"id": "edge-bff-to-resource-server",
"type": "arrow",
"x": 595.0,
"y": 148.0,
"width": 190.0,
"height": 129.0,
"x": 550.0,
"y": 217.0,
"width": 160.0,
"height": 81.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
@@ -169,21 +169,21 @@
"link": null,
"locked": false,
"points": [
[
190.0,
0.0
],
[
95.0,
0.0
],
[
95.0,
129.0
],
[
0.0,
129.0
0.0
],
[
80.0,
0.0
],
[
80.0,
81.0
],
[
160.0,
81.0
]
],
"lastCommittedPoint": null,
@@ -204,9 +204,9 @@
{
"id": "edge-label-bff-to-resource-server",
"type": "text",
"x": 634.0,
"y": 200.5,
"width": 160,
"x": 609.0,
"y": 245.5,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -232,21 +232,21 @@
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "downstream Bearer 요청",
"text": "Bearer 요청",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "downstream Bearer 요청",
"originalText": "Bearer 요청",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-bff-to-store",
"type": "arrow",
"x": 785.0,
"y": 59.0,
"width": 30.0,
"height": 90.0,
"x": 550.0,
"y": 135.0,
"width": 160.0,
"height": 64.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
@@ -270,27 +270,19 @@
"points": [
[
0.0,
71.0
64.0
],
[
30.0,
71.0
80.0,
64.0
],
[
30.0,
80.0,
0.0
],
[
0.0,
160.0,
0.0
],
[
0.0,
90.0
],
[
30.0,
90.0
]
],
"lastCommittedPoint": null,
@@ -311,9 +303,9 @@
{
"id": "edge-label-bff-to-store",
"type": "text",
"x": 698.5,
"y": 19.0,
"width": 184,
"x": 609.0,
"y": 155.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -339,20 +331,20 @@
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "현재 authorized client 조회",
"text": "client 조회",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "현재 authorized client 조회",
"originalText": "client 조회",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-browser-to-bff",
"type": "arrow",
"x": 265.0,
"y": 139.0,
"width": 330.0,
"x": 240.0,
"y": 208.0,
"width": 160.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -380,15 +372,15 @@
0.0
],
[
165.0,
80.0,
0.0
],
[
165.0,
80.0,
0.0
],
[
330.0,
160.0,
0.0
]
],
@@ -410,9 +402,9 @@
{
"id": "edge-label-browser-to-bff",
"type": "text",
"x": 330.0,
"y": 99.0,
"width": 200,
"x": 275.0,
"y": 168.0,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -438,20 +430,20 @@
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "AP3_SESSION · /bff/api/me",
"text": "AP3_SESSION",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "AP3_SESSION · /bff/api/me",
"originalText": "AP3_SESSION",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-browser",
"type": "rectangle",
"x": 75.0,
"y": 95.0,
"width": 190.0,
"x": 70.0,
"y": 164.0,
"width": 170.0,
"height": 88.0,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -479,9 +471,9 @@
{
"id": "node-label-browser",
"type": "text",
"x": 85.0,
"y": 105.0,
"width": 170.0,
"x": 80.0,
"y": 174.0,
"width": 150.0,
"height": 68.0,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -518,10 +510,10 @@
{
"id": "node-bff",
"type": "rectangle",
"x": 595.0,
"y": 95.0,
"width": 190.0,
"height": 88.0,
"x": 400.0,
"y": 155.5,
"width": 150.0,
"height": 105.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
@@ -548,10 +540,10 @@
{
"id": "node-label-bff",
"type": "text",
"x": 605.0,
"y": 105.0,
"width": 170.0,
"height": 68.0,
"x": 410.0,
"y": 165.5,
"width": 130.0,
"height": 85.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
@@ -576,20 +568,20 @@
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Spring BFF\nbff-confidential\noauth2Login",
"text": "Spring BFF\nbff-confidential\noauth2Login\n/bff/api/me",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Spring BFF\nbff-confidential\noauth2Login",
"originalText": "Spring BFF\nbff-confidential\noauth2Login\n/bff/api/me",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-authorized-client-store",
"type": "rectangle",
"x": 815.0,
"y": 95.0,
"width": 190.0,
"x": 710.0,
"y": 81.0,
"width": 153.0,
"height": 108.0,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -617,9 +609,9 @@
{
"id": "node-label-authorized-client-store",
"type": "text",
"x": 825.0,
"y": 105.0,
"width": 170.0,
"x": 720.0,
"y": 91.0,
"width": 133.0,
"height": 88.0,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -656,9 +648,9 @@
{
"id": "node-resource-server",
"type": "rectangle",
"x": 595.0,
"y": 240.0,
"width": 190.0,
"x": 710.0,
"y": 261.0,
"width": 153.0,
"height": 74.0,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -686,9 +678,9 @@
{
"id": "node-label-resource-server",
"type": "text",
"x": 605.0,
"y": 250.0,
"width": 170.0,
"x": 720.0,
"y": 271.0,
"width": 133.0,
"height": 54.0,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -2,14 +2,14 @@
"harness_version": "0.2.0",
"spec_id": "ap3-bff-architecture",
"spec_version": "1.1",
"spec_sha256": "20ed427be186390406589d69a75b848def944906ab94ff5b7448ead627b95bb2",
"spec_sha256": "ea691460c8489d465100ce6d75ca39b6d6283b51b5144fc9383f98d455b6b261",
"source_context": {
"document": "document.md",
"document_sha256": "df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371",
"document_sha256": "e2cc061f683294c937f089ad874cbf7a617f7313c07a92c2148fb11287a0da37",
"anchor": {
"kind": "marker",
"value": "ap3-bff-architecture",
"line": 172
"kind": "heading",
"value": "AP3: browser token 비노출과 application-owned session을 맞바꾼다",
"line": 190
}
},
"outputs": [
@@ -24,9 +24,9 @@
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "two-zone-pipeline",
"composition_profile": "component-flow",
"reference_ids": [
"localization-pipeline"
"payment-event-flow"
],
"diagram_only": true
}
@@ -9,8 +9,8 @@ flowchart LR
n2[("Authorized-client store")]
n3["Internal Resource Server"]
end
n0 -->|"AP3_SESSION · /bff/api/me"| n1
n1 -->|"현재 authorized client 조회"| n2
n1 -->|"downstream Bearer 요청"| n3
n0 -->|"AP3_SESSION"| n1
n1 -->|"client 조회"| n2
n1 -->|"Bearer 요청"| n3
classDef external stroke-dasharray: 6 4
class n0 external
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="385" viewBox="0 0 1080 385" role="img" aria-labelledby="diagram-title diagram-description">
<svg xmlns="http://www.w3.org/2000/svg" width="938" height="406" viewBox="0 0 938 406" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">AP3는 browser session을 BFF-owned Bearer 요청으로 바꾼다</title>
<desc id="diagram-description">왼쪽 browser session zone에는 OAuth token 없이 HttpOnly AP3_SESSION을 가진 브라우저가 있다. 오른쪽 server-side application zone에는 BFF, authorized-client store, 내부 Resource Server가 있다. 브라우저의 /bff/api/me 요청은 BFF에서 종료되고, BFF는 current authorized client를 조회해 server-held access token으로 Resource Server용 Bearer 요청을 조립한다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;ap3-bff-architecture&quot;,&quot;profile&quot;:&quot;two-zone-pipeline&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;document.md&quot;,&quot;document_sha256&quot;:&quot;df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;marker&quot;,&quot;value&quot;:&quot;ap3-bff-architecture&quot;,&quot;line&quot;:172}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;ap3-bff-architecture&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;document.md&quot;,&quot;document_sha256&quot;:&quot;e2cc061f683294c937f089ad874cbf7a617f7313c07a92c2148fb11287a0da37&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;heading&quot;,&quot;value&quot;:&quot;AP3: browser token 비노출과 application-owned session을 맞바꾼다&quot;,&quot;line&quot;:190}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
@@ -49,44 +49,45 @@
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="1080" height="385" />
<rect class="group-box" x="45.0" y="49.0" width="250.0" height="160.0" rx="8" />
<rect class="group-label-bg" x="59.0" y="39.0" width="190.0" height="22" />
<text class="group-label" x="69.0" y="54.0">Browser session boundary</text>
<rect class="group-box" x="565.0" y="49.0" width="470.0" height="291.0" rx="8" />
<rect class="group-label-bg" x="579.0" y="39.0" width="218.0" height="22" />
<text class="group-label" x="589.0" y="54.0">Server-side application path</text>
<polyline class="edge kind-request style-solid emphasis-primary" points="785.0,148.0 690.0,148.0 690.0,277.0 595.0,277.0" data-evidence="168-168" />
<rect class="edge-label-bg" x="638.0" y="198.5" width="152.0" height="22" rx="3" />
<text class="edge-label" x="714.0" y="213.5">downstream Bearer 요청</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="785.0,130.0 815.0,130.0 815.0,59.0 785.0,59.0 785.0,149.0 815.0,149.0" data-evidence="168-168" />
<rect class="edge-label-bg" x="704.5" y="17.0" width="172.1" height="22" rx="3" />
<text class="edge-label" x="790.5" y="32.0">현재 authorized client 조회</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="265.0,139.0 430.0,139.0 430.0,139.0 595.0,139.0" data-evidence="168-168" />
<rect class="edge-label-bg" x="337.2" y="97.0" width="185.5" height="22" rx="3" />
<text class="edge-label" x="430.0" y="112.0">AP3_SESSION · /bff/api/me</text>
<rect class="canvas" width="938" height="406" />
<rect class="group-box" x="40.0" y="118.0" width="230.0" height="160.0" rx="8" />
<rect class="group-label-bg" x="54.0" y="108.0" width="190.0" height="22" />
<text class="group-label" x="64.0" y="123.0">Browser session boundary</text>
<rect class="group-box" x="370.0" y="35.0" width="523.0" height="326.0" rx="8" />
<rect class="group-label-bg" x="384.0" y="25.0" width="218.0" height="22" />
<text class="group-label" x="394.0" y="40.0">Server-side application path</text>
<polyline class="edge kind-request style-solid emphasis-primary" points="550.0,217.0 630.0,217.0 630.0,298.0 710.0,298.0" data-evidence="194-194" />
<rect class="edge-label-bg" x="614.9" y="243.5" width="78.3" height="22" rx="3" />
<text class="edge-label" x="654.0" y="258.5">Bearer 요청</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="550.0,199.0 630.0,199.0 630.0,135.0 710.0,135.0" data-evidence="194-194" />
<rect class="edge-label-bg" x="614.9" y="153.0" width="78.3" height="22" rx="3" />
<text class="edge-label" x="654.0" y="168.0">client 조회</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="240.0,208.0 320.0,208.0 320.0,208.0 400.0,208.0" data-evidence="194-194" />
<rect class="edge-label-bg" x="274.1" y="166.0" width="91.7" height="22" rx="3" />
<text class="edge-label" x="320.0" y="181.0">AP3_SESSION</text>
<g id="node-browser">
<g class="actor-symbol emphasis-normal" data-evidence="166-168"><circle cx="170.0" cy="115.0" r="11.0" /><line x1="170.0" y1="131.0" x2="170.0" y2="154.0" /><line x1="152.0" y1="141.0" x2="188.0" y2="141.0" /><line x1="170.0" y1="154.0" x2="155.0" y2="171.0" /><line x1="170.0" y1="154.0" x2="185.0" y2="171.0" /></g>
<text class="node-label" x="170.0" y="176.0">Browser</text>
<g class="actor-symbol emphasis-normal" data-evidence="192-194"><circle cx="155.0" cy="184.0" r="11.0" /><line x1="155.0" y1="200.0" x2="155.0" y2="213.0" /><line x1="137.0" y1="210.0" x2="173.0" y2="210.0" /><line x1="155.0" y1="213.0" x2="140.0" y2="230.0" /><line x1="155.0" y1="213.0" x2="170.0" y2="230.0" /></g>
<text class="node-label" x="155.0" y="245.0">Browser</text>
</g>
<g id="node-bff">
<rect class="node-shape kind-service emphasis-primary role-service" data-evidence="166-170" x="595.0" y="95.0" width="190.0" height="88.0" rx="7" />
<text class="node-label" x="690.0" y="122.0">Spring BFF</text>
<line class="node-detail-divider" x1="609.0" y1="143.0" x2="771.0" y2="143.0" />
<text class="node-detail" x="611.0" y="160.0">bff-confidential</text>
<text class="node-detail" x="611.0" y="176.0">oauth2Login</text>
<rect class="node-shape kind-service emphasis-primary role-service" data-evidence="192-196" x="400.0" y="155.5" width="150.0" height="105.0" rx="7" />
<text class="node-label" x="475.0" y="182.5">Spring BFF</text>
<line class="node-detail-divider" x1="414.0" y1="203.5" x2="536.0" y2="203.5" />
<text class="node-detail" x="416.0" y="220.5">bff-confidential</text>
<text class="node-detail" x="416.0" y="236.5">oauth2Login</text>
<text class="node-detail" x="416.0" y="252.5">/bff/api/me</text>
</g>
<g id="node-authorized-client-store">
<rect class="node-shape kind-database emphasis-normal role-store" data-evidence="166-170" x="815.0" y="108.0" width="190.0" height="82.0" /><ellipse class="node-shape kind-database emphasis-normal role-store" cx="910.0" cy="108.0" rx="95.0" ry="13.0" /><path class="storage-bottom" d="M 815.0 190.0 A 95.0 13.0 0 0 0 1005.0 190.0" />
<text class="node-label" x="910.0" y="122.0">Authorized-client</text>
<text class="node-label" x="910.0" y="140.0">store</text>
<line class="node-detail-divider" x1="829.0" y1="161.0" x2="991.0" y2="161.0" />
<text class="node-detail" x="831.0" y="178.0">access token</text>
<text class="node-detail" x="831.0" y="194.0">refresh token</text>
<rect class="node-shape kind-database emphasis-normal role-store" data-evidence="192-196" x="710.0" y="81.0" width="153.0" height="108.0" rx="7" />
<text class="node-label" x="786.5" y="108.0">Authorized-client</text>
<text class="node-label" x="786.5" y="126.0">store</text>
<line class="node-detail-divider" x1="724.0" y1="147.0" x2="849.0" y2="147.0" />
<text class="node-detail" x="726.0" y="164.0">access token</text>
<text class="node-detail" x="726.0" y="180.0">refresh token</text>
</g>
<g id="node-resource-server">
<rect class="node-shape kind-service emphasis-normal role-sink" data-evidence="168-168" x="595.0" y="240.0" width="190.0" height="74.0" rx="7" />
<text class="node-label" x="690.0" y="267.0">Internal Resource</text>
<text class="node-label" x="690.0" y="285.0">Server</text>
<rect class="node-shape kind-service emphasis-normal role-sink" data-evidence="194-194" x="710.0" y="261.0" width="153.0" height="74.0" rx="7" />
<text class="node-label" x="786.5" y="288.0">Internal Resource</text>
<text class="node-label" x="786.5" y="306.0">Server</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

@@ -10,16 +10,16 @@ BFF CSRF endpoint가 raw XSRF cookie와 masked JSON token으로 분기하고, SP
## Elements and evidence
- **BFF · /bff/csrf** (service): CookieCsrfTokenRepository와 CsrfController를 통해 raw cookie와 masked JSON 응답을 만드는 endpoint. Evidence: L819L844.
- **Browser cookie · raw** (credential): Cookie repository가 path /에 설정하며 SPA가 document.cookie로 읽는 raw CSRF token. Evidence: L827L834, L844L856.
- **JSON body · masked** (data): Request attribute용 token을 masked한 JSON 표현으로, SPA는 token 값이 아니라 headerName만 사용한다. Evidence: L836L846.
- **SPA POST 조립** (component): Raw cookie 값을 X-XSRF-TOKEN header에 넣어 preference POST를 만드는 browser code. Evidence: L846L856, L862L870.
- **Spring CSRF filter** (security): Repository의 expected raw token과 submitted raw header를 controller 실행 전에 비교하는 방어선. Evidence: L854L854, L873L873, L897L904.
- **BFF · /bff/csrf** (service): CookieCsrfTokenRepository와 CsrfController를 통해 raw cookie와 masked JSON 응답을 만드는 endpoint. Evidence: L855L880.
- **Browser cookie · raw** (credential): Cookie repository가 path /에 설정하며 SPA가 document.cookie로 읽는 raw CSRF token. Evidence: L863L870, L880L892.
- **JSON body · masked** (data): Request attribute용 token을 masked한 JSON 표현으로, SPA는 token 값이 아니라 headerName만 사용한다. Evidence: L872L882.
- **SPA POST 조립** (component): Raw cookie 값을 X-XSRF-TOKEN header에 넣어 preference POST를 만드는 browser code. Evidence: L882L892, L898L906.
- **Spring CSRF filter** (security): Repository의 expected raw token과 submitted raw header를 controller 실행 전에 비교하는 방어선. Evidence: L890L890, L909L909, L933L940.
## Relationships
- **SPA POST 조립 → Spring CSRF filter:** Cookie raw = Header raw. Evidence: L848L873.
- **BFF · /bff/csrf → JSON body · masked:** masked JSON. Evidence: L836L844.
- **BFF · /bff/csrf → Browser cookie · raw:** Set-Cookie · raw. Evidence: L827L834.
- **JSON body · masked → SPA POST 조립:** headerName only. Evidence: L846L856.
- **Browser cookie · raw → SPA POST 조립:** document.cookie · raw. Evidence: L846L856.
- **SPA POST 조립 → Spring CSRF filter:** raw 대조. Evidence: L884L909.
- **BFF · /bff/csrf → JSON body · masked:** masked JSON. Evidence: L872L880.
- **BFF · /bff/csrf → Browser cookie · raw:** Set-Cookie · raw. Evidence: L863L870.
- **JSON body · masked → SPA POST 조립:** headerName. Evidence: L882L892.
- **Browser cookie · raw → SPA POST 조립:** raw cookie. Evidence: L882L892.
@@ -18,6 +18,6 @@ n4: "Spring CSRF filter" {
}
n0 -> n1: "Set-Cookie · raw"
n0 -> n2: "masked JSON"
n1 -> n3: "document.cookie · raw"
n2 -> n3: "headerName only"
n3 -> n4: "Cookie raw = Header raw"
n1 -> n3: "raw cookie"
n2 -> n3: "headerName"
n3 -> n4: "raw 대조"
@@ -9,7 +9,7 @@ digraph techviz {
n4 [label="Spring CSRF filter", shape=box, style="rounded,filled"];
n0 -> n1 [label="Set-Cookie · raw", style=solid];
n0 -> n2 [label="masked JSON", style=solid];
n1 -> n3 [label="document.cookie · raw", style=solid];
n2 -> n3 [label="headerName only", style=solid];
n3 -> n4 [label="Cookie raw = Header raw", style=solid];
n1 -> n3 [label="raw cookie", style=solid];
n2 -> n3 [label="headerName", style=solid];
n3 -> n4 [label="raw 대조", style=solid];
}
@@ -5,22 +5,22 @@
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_csrf-endpoint" value="BFF · /bff/csrf" tooltip="CookieCsrfTokenRepository와 CsrfController를 통해 raw cookie와 masked JSON 응답을 만드는 endpoint. | Evidence: L819-L844" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxCell id="n_csrf-endpoint" value="BFF · /bff/csrf" tooltip="CookieCsrfTokenRepository와 CsrfController를 통해 raw cookie와 masked JSON 응답을 만드는 endpoint. | Evidence: L855-L880" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="70.0" y="171.5" width="150.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="n_masked-json" value="JSON body · masked&lt;br/&gt;token = XOR/Base64&lt;br/&gt;headerName metadata&lt;br/&gt;POST token 값으로 미사용" tooltip="Request attribute용 token을 masked한 JSON 표현으로, SPA는 token 값이 아니라 headerName만 사용한다. | Evidence: L836-L846" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;shape=document;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxCell id="n_masked-json" value="JSON body · masked&lt;br/&gt;token = XOR/Base64&lt;br/&gt;headerName metadata&lt;br/&gt;POST 헤더 미사용" tooltip="Request attribute용 token을 masked한 JSON 표현으로, SPA는 token 값이 아니라 headerName만 사용한다. | Evidence: L872-L882" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;shape=document;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="380.0" y="60.0" width="220.0" height="110.0" as="geometry"/>
</mxCell>
<mxCell id="n_raw-cookie" value="Browser cookie · raw&lt;br/&gt;XSRF-TOKEN&lt;br/&gt;JavaScript-readable&lt;br/&gt;실제 header data source" tooltip="Cookie repository가 path /에 설정하며 SPA가 document.cookie로 읽는 raw CSRF token. | Evidence: L827-L834, L844-L856" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxCell id="n_raw-cookie" value="Browser cookie · raw&lt;br/&gt;XSRF-TOKEN&lt;br/&gt;JavaScript-readable&lt;br/&gt;실제 header data source" tooltip="Cookie repository가 path /에 설정하며 SPA가 document.cookie로 읽는 raw CSRF token. | Evidence: L863-L870, L880-L892" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="399.5" y="242.0" width="181.0" height="105.0" as="geometry"/>
</mxCell>
<mxCell id="n_post-assembler" value="SPA POST 조립&lt;br/&gt;Cookie 자동 첨부&lt;br/&gt;document.cookie raw → header&lt;br/&gt;JSON headerName만 사용" tooltip="Raw cookie 값을 X-XSRF-TOKEN header에 넣어 preference POST를 만드는 browser code. | Evidence: L846-L856, L862-L870" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxCell id="n_post-assembler" value="SPA POST 조립&lt;br/&gt;Cookie 자동 첨부&lt;br/&gt;document.cookie raw → header&lt;br/&gt;JSON headerName만 사용" tooltip="Raw cookie 값을 X-XSRF-TOKEN header에 넣어 preference POST를 만드는 browser code. | Evidence: L882-L892, L898-L906" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="760.0" y="151.0" width="230.0" height="105.0" as="geometry"/>
</mxCell>
<mxCell id="n_csrf-filter" value="Spring CSRF filter&lt;br/&gt;raw cookie = raw header 비교&lt;br/&gt;일치 → controller&lt;br/&gt;부재·불일치 → 403" tooltip="Repository의 expected raw token과 submitted raw header를 controller 실행 전에 비교하는 방어선. | Evidence: L854-L854, L873-L873, L897-L904" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxCell id="n_csrf-filter" value="Spring CSRF filter&lt;br/&gt;raw cookie = raw header 비교&lt;br/&gt;일치 → controller&lt;br/&gt;부재·불일치 → 403" tooltip="Repository의 expected raw token과 submitted raw header를 controller 실행 전에 비교하는 방어선. | Evidence: L890-L890, L909-L909, L933-L940" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="1150.0" y="151.0" width="216.0" height="105.0" as="geometry"/>
</mxCell>
<mxCell id="e_assembler-to-filter" value="Cookie raw = Header raw" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_post-assembler" target="n_csrf-filter">
<mxCell id="e_assembler-to-filter" value="raw 대조" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_post-assembler" target="n_csrf-filter">
<mxGeometry relative="1" as="geometry">
<mxPoint x="1070.0" y="175.5" as="offset"/>
</mxGeometry>
@@ -35,12 +35,12 @@
<mxPoint x="333.8" y="253.5" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_masked-json-to-assembler" value="headerName only" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_masked-json" target="n_post-assembler">
<mxCell id="e_masked-json-to-assembler" value="headerName" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_masked-json" target="n_post-assembler">
<mxGeometry relative="1" as="geometry">
<mxPoint x="704.0" y="154.8" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_raw-cookie-to-assembler" value="document.cookie · raw" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_raw-cookie" target="n_post-assembler">
<mxCell id="e_raw-cookie-to-assembler" value="raw cookie" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_raw-cookie" target="n_post-assembler">
<mxGeometry relative="1" as="geometry">
<mxPoint x="694.2" y="253.5" as="offset"/>
</mxGeometry>
@@ -66,9 +66,9 @@
{
"id": "edge-label-assembler-to-filter",
"type": "text",
"x": 978.0,
"x": 1025.0,
"y": 163.5,
"width": 184,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -94,11 +94,11 @@
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "Cookie raw = Header raw",
"text": "raw 대조",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Cookie raw = Header raw",
"originalText": "raw 대조",
"autoResize": true,
"lineHeight": 1.25
},
@@ -363,9 +363,9 @@
{
"id": "edge-label-masked-json-to-assembler",
"type": "text",
"x": 644.0,
"x": 659.0,
"y": 142.75,
"width": 120,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -391,11 +391,11 @@
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "headerName only",
"text": "headerName",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "headerName only",
"originalText": "headerName",
"autoResize": true,
"lineHeight": 1.25
},
@@ -462,9 +462,9 @@
{
"id": "edge-label-raw-cookie-to-assembler",
"type": "text",
"x": 610.25,
"x": 649.25,
"y": 241.5,
"width": 168,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -490,11 +490,11 @@
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "document.cookie · raw",
"text": "raw cookie",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "document.cookie · raw",
"originalText": "raw cookie",
"autoResize": true,
"lineHeight": 1.25
},
@@ -628,11 +628,11 @@
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "JSON body · masked\ntoken = XOR/Base64\nheaderName metadata\nPOST token 값으로 미사용",
"text": "JSON body · masked\ntoken = XOR/Base64\nheaderName metadata\nPOST 헤더 미사용",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "JSON body · masked\ntoken = XOR/Base64\nheaderName metadata\nPOST token 값으로 미사용",
"originalText": "JSON body · masked\ntoken = XOR/Base64\nheaderName metadata\nPOST 헤더 미사용",
"autoResize": true,
"lineHeight": 1.25
},
@@ -2,14 +2,14 @@
"harness_version": "0.2.0",
"spec_id": "ap3-csrf-boundary",
"spec_version": "1.1",
"spec_sha256": "fe7eed6a9b09c9ca13e32c04d27d1b245676e405bd3f432e363794918772e2b9",
"spec_sha256": "d10a5d627e8436b300e3bb55073f23b1dc389c50c81f572355af73183a5a52d1",
"source_context": {
"document": "document.md",
"document_sha256": "df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371",
"document_sha256": "e2cc061f683294c937f089ad874cbf7a617f7313c07a92c2148fb11287a0da37",
"anchor": {
"kind": "marker",
"value": "ap3-csrf-boundary",
"line": 858
"kind": "heading",
"value": "AP3 완주: session cookie가 BFF의 downstream Bearer가 되기까지",
"line": 681
}
},
"outputs": [
@@ -8,6 +8,6 @@ flowchart LR
n4["Spring CSRF filter"]
n0 -->|"Set-Cookie · raw"| n1
n0 -->|"masked JSON"| n2
n1 -->|"document.cookie · raw"| n3
n2 -->|"headerName only"| n3
n3 -->|"Cookie raw = Header raw"| n4
n1 -->|"raw cookie"| n3
n2 -->|"headerName"| n3
n3 -->|"raw 대조"| n4
@@ -2,7 +2,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1411" height="392" viewBox="0 0 1411 392" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">AP3의 masked CSRF 응답과 raw POST credential</title>
<desc id="diagram-description">왼쪽의 BFF CSRF endpoint에서 두 결과가 갈라진다. XSRF-TOKEN cookie에는 raw token이 저장되고 JSON body에는 XOR와 Base64로 masked된 token 및 headerName이 담긴다. 두 결과는 SPA의 POST 조립 단계로 모이지만, JSON에서는 headerName만 사용하고 실제 X-XSRF-TOKEN 값은 document.cookie에서 읽은 raw token이다. POST에는 같은 raw 값을 가진 cookie와 header가 함께 도달하고 Spring CSRF filter가 일치 여부를 확인한다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;ap3-csrf-boundary&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;document.md&quot;,&quot;document_sha256&quot;:&quot;df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;marker&quot;,&quot;value&quot;:&quot;ap3-csrf-boundary&quot;,&quot;line&quot;:858}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;ap3-csrf-boundary&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;document.md&quot;,&quot;document_sha256&quot;:&quot;e2cc061f683294c937f089ad874cbf7a617f7313c07a92c2148fb11287a0da37&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;heading&quot;,&quot;value&quot;:&quot;AP3 완주: session cookie가 BFF의 downstream Bearer가 되기까지&quot;,&quot;line&quot;:681}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
@@ -50,35 +50,35 @@
</style>
</defs>
<rect class="canvas" width="1411" height="392" />
<polyline class="edge kind-request style-solid emphasis-primary" points="990.0,203.5 1070.0,203.5 1070.0,203.5 1150.0,203.5" data-evidence="848-873" />
<rect class="edge-label-bg" x="984.0" y="161.5" width="172.1" height="22" rx="3" />
<text class="edge-label" x="1070.0" y="176.5">Cookie raw = Header raw</text>
<polyline class="edge kind-response style-solid emphasis-normal semantic-dashed" points="220.0,194.5 300.0,194.5 300.0,115.0 380.0,115.0" data-evidence="836-844" />
<polyline class="edge kind-request style-solid emphasis-primary" points="990.0,203.5 1070.0,203.5 1070.0,203.5 1150.0,203.5" data-evidence="884-909" />
<rect class="edge-label-bg" x="1040.9" y="161.5" width="58.2" height="22" rx="3" />
<text class="edge-label" x="1070.0" y="176.5">raw 대조</text>
<polyline class="edge kind-response style-solid emphasis-normal semantic-dashed" points="220.0,194.5 300.0,194.5 300.0,115.0 380.0,115.0" data-evidence="872-880" />
<rect class="edge-label-bg" x="278.1" y="140.8" width="91.7" height="22" rx="3" />
<text class="edge-label" x="324.0" y="155.8">masked JSON</text>
<polyline class="edge kind-response style-solid emphasis-normal semantic-dashed" points="220.0,212.5 309.8,212.5 309.8,294.5 399.5,294.5" data-evidence="827-834" />
<polyline class="edge kind-response style-solid emphasis-normal semantic-dashed" points="220.0,212.5 309.8,212.5 309.8,294.5 399.5,294.5" data-evidence="863-870" />
<rect class="edge-label-bg" x="271.1" y="239.5" width="125.2" height="22" rx="3" />
<text class="edge-label" x="333.8" y="254.5">Set-Cookie · raw</text>
<polyline class="edge kind-data style-solid emphasis-normal" points="600.0,115.0 680.0,115.0 680.0,194.5 760.0,194.5" data-evidence="846-856" />
<rect class="edge-label-bg" x="644.8" y="140.8" width="118.5" height="22" rx="3" />
<text class="edge-label" x="704.0" y="155.8">headerName only</text>
<polyline class="edge kind-data style-solid emphasis-primary" points="580.5,294.5 670.2,294.5 670.2,212.5 760.0,212.5" data-evidence="846-856" />
<rect class="edge-label-bg" x="614.9" y="239.5" width="158.7" height="22" rx="3" />
<text class="edge-label" x="694.2" y="254.5">document.cookie · raw</text>
<polyline class="edge kind-data style-solid emphasis-normal" points="600.0,115.0 680.0,115.0 680.0,194.5 760.0,194.5" data-evidence="882-892" />
<rect class="edge-label-bg" x="661.5" y="140.8" width="85.0" height="22" rx="3" />
<text class="edge-label" x="704.0" y="155.8">headerName</text>
<polyline class="edge kind-data style-solid emphasis-primary" points="580.5,294.5 670.2,294.5 670.2,212.5 760.0,212.5" data-evidence="882-892" />
<rect class="edge-label-bg" x="651.8" y="239.5" width="85.0" height="22" rx="3" />
<text class="edge-label" x="694.2" y="254.5">raw cookie</text>
<g id="node-csrf-endpoint">
<rect class="node-shape kind-service emphasis-normal role-source" data-evidence="819-844" x="70.0" y="171.5" width="150.0" height="64.0" rx="7" />
<rect class="node-shape kind-service emphasis-normal role-source" data-evidence="855-880" x="70.0" y="171.5" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="145.0" y="201.5">BFF · /bff/csrf</text>
</g>
<g id="node-masked-json">
<path class="node-shape kind-data emphasis-normal role-store" data-evidence="836-846" d="M 380.0 60.0 H 569.2 L 600.0 90.8 V 170.0 H 380.0 Z M 569.2 60.0 V 90.8 H 600.0" />
<path class="node-shape kind-data emphasis-normal role-store" data-evidence="872-882" d="M 380.0 60.0 H 569.2 L 600.0 90.8 V 170.0 H 380.0 Z M 569.2 60.0 V 90.8 H 600.0" />
<text class="node-label" x="490.0" y="89.0">JSON body · masked</text>
<line class="node-detail-divider" x1="394.0" y1="110.0" x2="586.0" y2="110.0" />
<text class="node-detail" x="396.0" y="127.0">token = XOR/Base64</text>
<text class="node-detail" x="396.0" y="143.0">headerName metadata</text>
<text class="node-detail" x="396.0" y="159.0">POST token 값으로 미사용</text>
<text class="node-detail" x="396.0" y="159.0">POST 헤더 미사용</text>
</g>
<g id="node-raw-cookie">
<rect class="node-shape kind-credential emphasis-primary role-store" data-evidence="827-834,844-856" x="399.5" y="242.0" width="181.0" height="105.0" rx="7" />
<rect class="node-shape kind-credential emphasis-primary role-store" data-evidence="863-870,880-892" x="399.5" y="242.0" width="181.0" height="105.0" rx="7" />
<text class="node-label" x="490.0" y="269.0">Browser cookie · raw</text>
<line class="node-detail-divider" x1="413.5" y1="290.0" x2="566.5" y2="290.0" />
<text class="node-detail" x="415.5" y="307.0">XSRF-TOKEN</text>
@@ -86,7 +86,7 @@
<text class="node-detail" x="415.5" y="339.0">실제 header data source</text>
</g>
<g id="node-post-assembler">
<rect class="node-shape kind-component emphasis-normal role-service" data-evidence="846-856,862-870" x="760.0" y="151.0" width="230.0" height="105.0" rx="7" />
<rect class="node-shape kind-component emphasis-normal role-service" data-evidence="882-892,898-906" x="760.0" y="151.0" width="230.0" height="105.0" rx="7" />
<text class="node-label" x="875.0" y="178.0">SPA POST 조립</text>
<line class="node-detail-divider" x1="774.0" y1="199.0" x2="976.0" y2="199.0" />
<text class="node-detail" x="776.0" y="216.0">Cookie 자동 첨부</text>
@@ -94,7 +94,7 @@
<text class="node-detail" x="776.0" y="248.0">JSON headerName만 사용</text>
</g>
<g id="node-csrf-filter">
<rect class="node-shape kind-security emphasis-normal role-sink" data-evidence="854-854,873-873,897-904" x="1150.0" y="151.0" width="216.0" height="105.0" rx="7" />
<rect class="node-shape kind-security emphasis-normal role-sink" data-evidence="890-890,909-909,933-940" x="1150.0" y="151.0" width="216.0" height="105.0" rx="7" />
<text class="node-label" x="1258.0" y="178.0">Spring CSRF filter</text>
<line class="node-detail-divider" x1="1164.0" y1="199.0" x2="1352.0" y2="199.0" />
<text class="node-detail" x="1166.0" y="216.0">raw cookie = raw header 비교</text>

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -10,15 +10,15 @@
## Elements and evidence
- **Boundary: External browser** (system): AP4_SESSION을 애플리케이션의 공개 entry point로 보내는 외부 client 경계. Evidence: L914L919, L980L999.
- **Boundary: AP4 deployment path** (system): Host-published Nginx와 host 비공개 oauth2-proxy 및 Spring app이 연결되는 deployment 경계. Evidence: L914L914, L1093L1093.
- **Browser** (actor): Opaque AP4_SESSION을 자동 첨부해 공개 edge endpoint를 호출한다. Evidence: L980L999.
- **Nginx edge** (service): Internal auth subrequest를 만들고 검증 결과를 allowlisted upstream header로 다시 조립하는 공개 gateway. Evidence: L914L928, L1009L1028.
- **Spring upstream** (service): 정제된 user header와 Nginx-owned internal token을 함께 검증하는 /edge/me upstream. Evidence: L1017L1037, L1065L1078.
- **oauth2-proxy** (service): Confidential edge-proxy client이자 AP4_SESSION의 유효성을 판단하는 host 비공개 auth service. Evidence: L940L990, L1009L1014.
- **Boundary: External browser** (system): AP4_SESSION을 애플리케이션의 공개 entry point로 보내는 외부 client 경계. Evidence: L952L957, L1018L1039.
- **Boundary: AP4 deployment path** (system): Host-published Nginx와 host 비공개 oauth2-proxy 및 Spring app이 연결되는 deployment 경계. Evidence: L952L952, L1133L1133.
- **Browser** (actor): Opaque AP4_SESSION을 자동 첨부해 공개 edge endpoint를 호출한다. Evidence: L1018L1039.
- **Nginx edge** (service): Internal auth subrequest를 만들고 검증 결과를 allowlisted upstream header로 다시 조립하는 공개 gateway. Evidence: L952L966, L1049L1068.
- **Spring upstream** (service): 정제된 user header와 Nginx-owned internal token을 함께 검증하는 /edge/me upstream. Evidence: L1057L1077, L1105L1118.
- **oauth2-proxy** (service): Confidential edge-proxy client이자 AP4_SESSION의 유효성을 판단하는 host 비공개 auth service. Evidence: L978L1028, L1049L1054.
## Relationships
- **Browser → Nginx edge:** GET /api/edge · AP4_SESSION. Evidence: L994L999.
- **Nginx edge → oauth2-proxy:** internal /oauth2/auth subrequest. Evidence: L922L928, L1009L1009.
- **Nginx edge → Spring upstream:** allowlisted identity + internal token. Evidence: L1017L1026, L1065L1078.
- **Browser → Nginx edge:** AP4_SESSION. Evidence: L1034L1039.
- **Nginx edge → oauth2-proxy:** auth subrequest. Evidence: L960L966, L1049L1049.
- **Nginx edge → Spring upstream:** identity. Evidence: L1057L1066, L1105L1118.
@@ -17,6 +17,6 @@ g1: "AP4 deployment path" {
shape: rectangle
}
}
g0.n0 -> g1.n1: "GET /api/edge · AP4_SESSION"
g1.n1 -> g1.n3: "internal /oauth2/auth subrequest"
g1.n1 -> g1.n2: "allowlisted identity + internal token"
g0.n0 -> g1.n1: "AP4_SESSION"
g1.n1 -> g1.n3: "auth subrequest"
g1.n1 -> g1.n2: "identity"
@@ -16,7 +16,7 @@ digraph techviz {
n2 [label="Spring upstream", shape=box, style="rounded,filled"];
n3 [label="oauth2-proxy", shape=box, style="rounded,filled"];
}
n0 -> n1 [label="GET /api/edge · AP4_SESSION", style=solid];
n1 -> n3 [label="internal /oauth2/auth subrequest", style=solid];
n1 -> n2 [label="allowlisted identity + internal token", style=solid];
n0 -> n1 [label="AP4_SESSION", style=solid];
n1 -> n3 [label="auth subrequest", style=solid];
n1 -> n2 [label="identity", style=solid];
}
@@ -1,41 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
<diagram id="ap4-edge-trust-architecture" name="AP4는 공개 edge에서 session을 정제된 upstream identity로 바꾼다">
<mxGraphModel dx="1080" dy="416" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1080" pageHeight="1169" math="0" shadow="0">
<mxGraphModel dx="1039" dy="451" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1039" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="g_external-client-zone" value="External browser" style="swimlane;html=1;rounded=1;startSize=30;horizontal=1;dashed=1;strokeWidth=1.5;fillColor=#f7f9fb;strokeColor=#66788a;fontStyle=1;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="45.0" y="49.0" width="250.0" height="156.0" as="geometry"/>
<mxGeometry x="40.0" y="142.5" width="230.0" height="156.0" as="geometry"/>
</mxCell>
<mxCell id="g_ap4-deployment-zone" value="AP4 deployment path" style="swimlane;html=1;rounded=1;startSize=30;horizontal=1;dashed=1;strokeWidth=1.5;fillColor=#f7f9fb;strokeColor=#66788a;fontStyle=1;fontSize=13;" vertex="1" parent="1">
<mxGeometry x="565.0" y="49.0" width="470.0" height="322.0" as="geometry"/>
<mxGeometry x="370.0" y="35.0" width="624.0" height="371.0" as="geometry"/>
</mxCell>
<mxCell id="n_browser" value="Browser&lt;br/&gt;HttpOnly AP4_SESSION" tooltip="Opaque AP4_SESSION을 자동 첨부해 공개 edge endpoint를 호출한다. | Evidence: L980-L999" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;dashed=1;fillColor=#f5f7fa;" vertex="1" parent="1">
<mxGeometry x="75.0" y="95.0" width="190.0" height="84.0" as="geometry"/>
<mxCell id="n_browser" value="Browser&lt;br/&gt;HttpOnly AP4_SESSION" tooltip="Opaque AP4_SESSION을 자동 첨부해 공개 edge endpoint를 호출한다. | Evidence: L1018-L1039" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;dashed=1;fillColor=#f5f7fa;" vertex="1" parent="1">
<mxGeometry x="70.0" y="188.5" width="170.0" height="84.0" as="geometry"/>
</mxCell>
<mxCell id="n_nginx" value="Nginx edge&lt;br/&gt;auth_request&lt;br/&gt;client header overwrite" tooltip="Internal auth subrequest를 만들고 검증 결과를 allowlisted upstream header로 다시 조립하는 공개 gateway. | Evidence: L914-L928, L1009-L1028" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="595.0" y="95.0" width="190.0" height="88.0" as="geometry"/>
<mxCell id="n_nginx" value="Nginx edge&lt;br/&gt;/api/edge&lt;br/&gt;auth_request&lt;br/&gt;client header overwrite" tooltip="Internal auth subrequest를 만들고 검증 결과를 allowlisted upstream header로 다시 조립하는 공개 gateway. | Evidence: L952-L966, L1049-L1068" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="400.0" y="178.0" width="195.0" height="105.0" as="geometry"/>
</mxCell>
<mxCell id="n_spring-upstream" value="Spring upstream&lt;br/&gt;X-Auth-Request-User&lt;br/&gt;X-Auth-Request-Email&lt;br/&gt;X-Internal-Auth-Token&lt;br/&gt;/edge/me controller check" tooltip="정제된 user header와 Nginx-owned internal token을 함께 검증하는 /edge/me upstream. | Evidence: L1017-L1037, L1065-L1078" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="815.0" y="95.0" width="190.0" height="122.0" as="geometry"/>
<mxCell id="n_oauth2-proxy" value="oauth2-proxy&lt;br/&gt;edge-proxy client&lt;br/&gt;minimal cookie session&lt;br/&gt;returns user · email" tooltip="Confidential edge-proxy client이자 AP4_SESSION의 유효성을 판단하는 host 비공개 auth service. | Evidence: L978-L1028, L1049-L1054" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="765.5" y="81.0" width="188.0" height="105.0" as="geometry"/>
</mxCell>
<mxCell id="n_oauth2-proxy" value="oauth2-proxy&lt;br/&gt;edge-proxy client&lt;br/&gt;minimal cookie session&lt;br/&gt;returns user · email" tooltip="Confidential edge-proxy client이자 AP4_SESSION의 유효성을 판단하는 host 비공개 auth service. | Evidence: L940-L990, L1009-L1014" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="595.0" y="240.0" width="190.0" height="105.0" as="geometry"/>
<mxCell id="n_spring-upstream" value="Spring upstream&lt;br/&gt;X-Auth-Request-User&lt;br/&gt;X-Auth-Request-Email&lt;br/&gt;X-Internal-Auth-Token&lt;br/&gt;/edge/me controller check" tooltip="정제된 user header와 Nginx-owned internal token을 함께 검증하는 /edge/me upstream. | Evidence: L1057-L1077, L1105-L1118" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="755.0" y="258.0" width="209.0" height="122.0" as="geometry"/>
</mxCell>
<mxCell id="e_browser-to-nginx" value="GET /api/edge · AP4_SESSION" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_browser" target="n_nginx">
<mxCell id="e_browser-to-nginx" value="AP4_SESSION" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_browser" target="n_nginx">
<mxGeometry relative="1" as="geometry">
<mxPoint x="454.0" y="138.0" as="offset"/>
<mxPoint x="320.0" y="202.5" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_nginx-to-oauth2-proxy" value="internal /oauth2/auth subrequest" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_nginx" target="n_oauth2-proxy">
<mxCell id="e_nginx-to-oauth2-proxy" value="auth subrequest" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_nginx" target="n_oauth2-proxy">
<mxGeometry relative="1" as="geometry">
<mxPoint x="714.0" y="220.2" as="offset"/>
<mxPoint x="704.2" y="177.5" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_nginx-to-upstream" value="allowlisted identity + internal token" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_nginx" target="n_spring-upstream">
<mxCell id="e_nginx-to-upstream" value="identity" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_nginx" target="n_spring-upstream">
<mxGeometry relative="1" as="geometry">
<mxPoint x="787.0" y="31.0" as="offset"/>
<mxPoint x="699.0" y="279.2" as="offset"/>
</mxGeometry>
</mxCell>
</root>
@@ -6,9 +6,9 @@
{
"id": "group-external-client-zone",
"type": "rectangle",
"x": 45.0,
"y": 49.0,
"width": 250.0,
"x": 40.0,
"y": 142.5,
"width": 230.0,
"height": 156.0,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -36,8 +36,8 @@
{
"id": "group-label-external-client-zone",
"type": "text",
"x": 61.0,
"y": 55.0,
"x": 56.0,
"y": 148.5,
"width": 144,
"height": 24,
"angle": 0,
@@ -75,10 +75,10 @@
{
"id": "group-ap4-deployment-zone",
"type": "rectangle",
"x": 565.0,
"y": 49.0,
"width": 470.0,
"height": 322.0,
"x": 370.0,
"y": 35.0,
"width": 624.0,
"height": 371.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#f8f9fa",
@@ -105,8 +105,8 @@
{
"id": "group-label-ap4-deployment-zone",
"type": "text",
"x": 581.0,
"y": 55.0,
"x": 386.0,
"y": 41.0,
"width": 171,
"height": 24,
"angle": 0,
@@ -144,10 +144,10 @@
{
"id": "edge-browser-to-nginx",
"type": "arrow",
"x": 265.0,
"y": 137.0,
"width": 330.0,
"height": 2.0,
"x": 240.0,
"y": 230.5,
"width": 160.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
@@ -174,16 +174,16 @@
0.0
],
[
165.0,
80.0,
0.0
],
[
165.0,
2.0
80.0,
0.0
],
[
330.0,
2.0
160.0,
0.0
]
],
"lastCommittedPoint": null,
@@ -204,9 +204,9 @@
{
"id": "edge-label-browser-to-nginx",
"type": "text",
"x": 346.0,
"y": 126.0,
"width": 216,
"x": 275.0,
"y": 190.5,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -232,11 +232,11 @@
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "GET /api/edge · AP4_SESSION",
"text": "AP4_SESSION",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "GET /api/edge · AP4_SESSION",
"originalText": "AP4_SESSION",
"autoResize": true,
"lineHeight": 1.25
},
@@ -244,9 +244,9 @@
"id": "edge-nginx-to-oauth2-proxy",
"type": "arrow",
"x": 595.0,
"y": 148.0,
"width": 190.0,
"height": 144.5,
"y": 133.5,
"width": 170.5,
"height": 88.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
@@ -268,21 +268,21 @@
"link": null,
"locked": false,
"points": [
[
190.0,
0.0
],
[
95.0,
0.0
],
[
95.0,
144.5
],
[
0.0,
144.5
88.0
],
[
85.25,
88.0
],
[
85.25,
0.0
],
[
170.5,
0.0
]
],
"lastCommittedPoint": null,
@@ -303,9 +303,9 @@
{
"id": "edge-label-nginx-to-oauth2-proxy",
"type": "text",
"x": 586.0,
"y": 208.25,
"width": 256,
"x": 644.25,
"y": 165.5,
"width": 120,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -331,21 +331,21 @@
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "internal /oauth2/auth subrequest",
"text": "auth subrequest",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "internal /oauth2/auth subrequest",
"originalText": "auth subrequest",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-nginx-to-upstream",
"type": "arrow",
"x": 785.0,
"y": 59.0,
"width": 30.0,
"height": 97.0,
"x": 595.0,
"y": 239.5,
"width": 160.0,
"height": 79.5,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
@@ -369,27 +369,19 @@
"points": [
[
0.0,
71.0
],
[
30.0,
71.0
],
[
30.0,
0.0
],
[
0.0,
80.0,
0.0
],
[
0.0,
97.0
80.0,
79.5
],
[
30.0,
97.0
160.0,
79.5
]
],
"lastCommittedPoint": null,
@@ -410,9 +402,9 @@
{
"id": "edge-label-nginx-to-upstream",
"type": "text",
"x": 639.0,
"y": 19.0,
"width": 296,
"x": 654.0,
"y": 267.25,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -438,20 +430,20 @@
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "allowlisted identity + internal token",
"text": "identity",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "allowlisted identity + internal token",
"originalText": "identity",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-browser",
"type": "rectangle",
"x": 75.0,
"y": 95.0,
"width": 190.0,
"x": 70.0,
"y": 188.5,
"width": 170.0,
"height": 84.0,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -479,9 +471,9 @@
{
"id": "node-label-browser",
"type": "text",
"x": 85.0,
"y": 105.0,
"width": 170.0,
"x": 80.0,
"y": 198.5,
"width": 150.0,
"height": 64.0,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -518,10 +510,10 @@
{
"id": "node-nginx",
"type": "rectangle",
"x": 595.0,
"y": 95.0,
"width": 190.0,
"height": 88.0,
"x": 400.0,
"y": 178.0,
"width": 195.0,
"height": 105.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
@@ -548,10 +540,10 @@
{
"id": "node-label-nginx",
"type": "text",
"x": 605.0,
"y": 105.0,
"width": 170.0,
"height": 68.0,
"x": 410.0,
"y": 188.0,
"width": 175.0,
"height": 85.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
@@ -576,89 +568,20 @@
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Nginx edge\nauth_request\nclient header overwrite",
"text": "Nginx edge\n/api/edge\nauth_request\nclient header overwrite",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Nginx edge\nauth_request\nclient header overwrite",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-spring-upstream",
"type": "rectangle",
"x": 815.0,
"y": 95.0,
"width": 190.0,
"height": 122.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1886263514,
"version": 1,
"versionNonce": 1117537622,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-spring-upstream",
"type": "text",
"x": 825.0,
"y": 105.0,
"width": 170.0,
"height": 102.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1165629413,
"version": 1,
"versionNonce": 1538645325,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Spring upstream\nX-Auth-Request-User\nX-Auth-Request-Email\nX-Internal-Auth-Token\n/edge/me controller check",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Spring upstream\nX-Auth-Request-User\nX-Auth-Request-Email\nX-Internal-Auth-Token\n/edge/me controller check",
"originalText": "Nginx edge\n/api/edge\nauth_request\nclient header overwrite",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-oauth2-proxy",
"type": "rectangle",
"x": 595.0,
"y": 240.0,
"width": 190.0,
"x": 765.5,
"y": 81.0,
"width": 188.0,
"height": 105.0,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -686,9 +609,9 @@
{
"id": "node-label-oauth2-proxy",
"type": "text",
"x": 605.0,
"y": 250.0,
"width": 170.0,
"x": 775.5,
"y": 91.0,
"width": 168.0,
"height": 85.0,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -721,6 +644,75 @@
"originalText": "oauth2-proxy\nedge-proxy client\nminimal cookie session\nreturns user · email",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-spring-upstream",
"type": "rectangle",
"x": 755.0,
"y": 258.0,
"width": 209.0,
"height": 122.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1886263514,
"version": 1,
"versionNonce": 1117537622,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-spring-upstream",
"type": "text",
"x": 765.0,
"y": 268.0,
"width": 189.0,
"height": 102.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1165629413,
"version": 1,
"versionNonce": 1538645325,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Spring upstream\nX-Auth-Request-User\nX-Auth-Request-Email\nX-Internal-Auth-Token\n/edge/me controller check",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Spring upstream\nX-Auth-Request-User\nX-Auth-Request-Email\nX-Internal-Auth-Token\n/edge/me controller check",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
@@ -2,14 +2,14 @@
"harness_version": "0.2.0",
"spec_id": "ap4-edge-trust-architecture",
"spec_version": "1.1",
"spec_sha256": "c236660298ed5ff0bc3b8ce960fccdffefe7d45047f379c4067bfd2934d0e0b9",
"spec_sha256": "8ebbe718a7c7581b0e6d398fbf0e67e0c1ac32ac06e4d1b2f1127ac0a5649935",
"source_context": {
"document": "document.md",
"document_sha256": "df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371",
"document_sha256": "e2cc061f683294c937f089ad874cbf7a617f7313c07a92c2148fb11287a0da37",
"anchor": {
"kind": "marker",
"value": "ap4-edge-trust-architecture",
"line": 182
"kind": "heading",
"value": "AP4: OAuth를 모르는 upstream 앞에서 신뢰 경로를 만든다",
"line": 202
}
},
"outputs": [
@@ -24,9 +24,9 @@
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "two-zone-pipeline",
"composition_profile": "component-flow",
"reference_ids": [
"localization-pipeline"
"payment-event-flow"
],
"diagram_only": true
}
@@ -9,8 +9,8 @@ flowchart LR
n2["Spring upstream"]
n3["oauth2-proxy"]
end
n0 -->|"GET /api/edge · AP4_SESSION"| n1
n1 -->|"internal /oauth2/auth subrequest"| n3
n1 -->|"allowlisted identity + internal token"| n2
n0 -->|"AP4_SESSION"| n1
n1 -->|"auth subrequest"| n3
n1 -->|"identity"| n2
classDef external stroke-dasharray: 6 4
class n0 external
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="416" viewBox="0 0 1080 416" role="img" aria-labelledby="diagram-title diagram-description">
<svg xmlns="http://www.w3.org/2000/svg" width="1039" height="451" viewBox="0 0 1039 451" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">AP4는 공개 edge에서 session을 정제된 upstream identity로 바꾼다</title>
<desc id="diagram-description">왼쪽 외부 브라우저가 AP4_SESSION과 함께 AP4 deployment path의 공개 Nginx entry point를 호출한다. 같은 deployment path에서 Nginx는 host 비공개 oauth2-proxy의 internal /oauth2/auth endpoint에 subrequest를 보내 session을 검증하고 user와 email 결과를 받는다. 이어서 client가 보낸 동명 header를 덮어쓰고 Nginx-owned identity header와 internal token을 host 비공개 Spring upstream의 /edge/me로 전달한다. Upstream은 user header와 internal token을 함께 확인하며 JWT를 직접 입력으로 받지 않는다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;ap4-edge-trust-architecture&quot;,&quot;profile&quot;:&quot;two-zone-pipeline&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;document.md&quot;,&quot;document_sha256&quot;:&quot;df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;marker&quot;,&quot;value&quot;:&quot;ap4-edge-trust-architecture&quot;,&quot;line&quot;:182}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;ap4-edge-trust-architecture&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;document.md&quot;,&quot;document_sha256&quot;:&quot;e2cc061f683294c937f089ad874cbf7a617f7313c07a92c2148fb11287a0da37&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;heading&quot;,&quot;value&quot;:&quot;AP4: OAuth를 모르는 upstream 앞에서 신뢰 경로를 만든다&quot;,&quot;line&quot;:202}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
@@ -49,48 +49,49 @@
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="1080" height="416" />
<rect class="group-box" x="45.0" y="49.0" width="250.0" height="156.0" rx="8" />
<rect class="group-label-bg" x="59.0" y="39.0" width="134.0" height="22" />
<text class="group-label" x="69.0" y="54.0">External browser</text>
<rect class="group-box" x="565.0" y="49.0" width="470.0" height="322.0" rx="8" />
<rect class="group-label-bg" x="579.0" y="39.0" width="155.0" height="22" />
<text class="group-label" x="589.0" y="54.0">AP4 deployment path</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="265.0,137.0 430.0,137.0 430.0,139.0 595.0,139.0" data-evidence="994-999" />
<rect class="edge-label-bg" x="354.6" y="124.0" width="198.9" height="22" rx="3" />
<text class="edge-label" x="454.0" y="139.0">GET /api/edge · AP4_SESSION</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="785.0,148.0 690.0,148.0 690.0,292.5 595.0,292.5" data-evidence="922-928,1009-1009" />
<rect class="edge-label-bg" x="597.8" y="206.2" width="232.4" height="22" rx="3" />
<text class="edge-label" x="714.0" y="221.2">internal /oauth2/auth subrequest</text>
<polyline class="edge kind-data style-solid emphasis-primary" points="785.0,130.0 815.0,130.0 815.0,59.0 785.0,59.0 785.0,156.0 815.0,156.0" data-evidence="1017-1026,1065-1078" />
<rect class="edge-label-bg" x="654.0" y="17.0" width="265.9" height="22" rx="3" />
<text class="edge-label" x="787.0" y="32.0">allowlisted identity + internal token</text>
<rect class="canvas" width="1039" height="451" />
<rect class="group-box" x="40.0" y="142.5" width="230.0" height="156.0" rx="8" />
<rect class="group-label-bg" x="54.0" y="132.5" width="134.0" height="22" />
<text class="group-label" x="64.0" y="147.5">External browser</text>
<rect class="group-box" x="370.0" y="35.0" width="624.0" height="371.0" rx="8" />
<rect class="group-label-bg" x="384.0" y="25.0" width="155.0" height="22" />
<text class="group-label" x="394.0" y="40.0">AP4 deployment path</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="240.0,230.5 320.0,230.5 320.0,230.5 400.0,230.5" data-evidence="1034-1039" />
<rect class="edge-label-bg" x="274.1" y="188.5" width="91.7" height="22" rx="3" />
<text class="edge-label" x="320.0" y="203.5">AP4_SESSION</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="595.0,221.5 680.2,221.5 680.2,133.5 765.5,133.5" data-evidence="960-966,1049-1049" />
<rect class="edge-label-bg" x="645.0" y="163.5" width="118.5" height="22" rx="3" />
<text class="edge-label" x="704.2" y="178.5">auth subrequest</text>
<polyline class="edge kind-data style-solid emphasis-primary" points="595.0,239.5 675.0,239.5 675.0,319.0 755.0,319.0" data-evidence="1057-1066,1105-1118" />
<rect class="edge-label-bg" x="663.2" y="265.2" width="71.6" height="22" rx="3" />
<text class="edge-label" x="699.0" y="280.2">identity</text>
<g id="node-browser">
<g class="actor-symbol emphasis-normal" data-evidence="980-999"><circle cx="170.0" cy="115.0" r="11.0" /><line x1="170.0" y1="131.0" x2="170.0" y2="150.0" /><line x1="152.0" y1="141.0" x2="188.0" y2="141.0" /><line x1="170.0" y1="150.0" x2="155.0" y2="167.0" /><line x1="170.0" y1="150.0" x2="185.0" y2="167.0" /></g>
<text class="node-label" x="170.0" y="172.0">Browser</text>
<g class="actor-symbol emphasis-normal" data-evidence="1018-1039"><circle cx="155.0" cy="208.5" r="11.0" /><line x1="155.0" y1="224.5" x2="155.0" y2="233.5" /><line x1="137.0" y1="234.5" x2="173.0" y2="234.5" /><line x1="155.0" y1="233.5" x2="140.0" y2="250.5" /><line x1="155.0" y1="233.5" x2="170.0" y2="250.5" /></g>
<text class="node-label" x="155.0" y="265.5">Browser</text>
</g>
<g id="node-nginx">
<rect class="node-shape kind-service emphasis-primary role-service" data-evidence="914-928,1009-1028" x="595.0" y="95.0" width="190.0" height="88.0" rx="7" />
<text class="node-label" x="690.0" y="122.0">Nginx edge</text>
<line class="node-detail-divider" x1="609.0" y1="143.0" x2="771.0" y2="143.0" />
<text class="node-detail" x="611.0" y="160.0">auth_request</text>
<text class="node-detail" x="611.0" y="176.0">client header overwrite</text>
</g>
<g id="node-spring-upstream">
<rect class="node-shape kind-service emphasis-normal role-sink" data-evidence="1017-1037,1065-1078" x="815.0" y="95.0" width="190.0" height="122.0" rx="7" />
<text class="node-label" x="910.0" y="122.0">Spring upstream</text>
<line class="node-detail-divider" x1="829.0" y1="143.0" x2="991.0" y2="143.0" />
<text class="node-detail" x="831.0" y="160.0">X-Auth-Request-User</text>
<text class="node-detail" x="831.0" y="176.0">X-Auth-Request-Email</text>
<text class="node-detail" x="831.0" y="192.0">X-Internal-Auth-Token</text>
<text class="node-detail" x="831.0" y="208.0">/edge/me controller check</text>
<rect class="node-shape kind-service emphasis-primary role-service" data-evidence="952-966,1049-1068" x="400.0" y="178.0" width="195.0" height="105.0" rx="7" />
<text class="node-label" x="497.5" y="205.0">Nginx edge</text>
<line class="node-detail-divider" x1="414.0" y1="226.0" x2="581.0" y2="226.0" />
<text class="node-detail" x="416.0" y="243.0">/api/edge</text>
<text class="node-detail" x="416.0" y="259.0">auth_request</text>
<text class="node-detail" x="416.0" y="275.0">client header overwrite</text>
</g>
<g id="node-oauth2-proxy">
<rect class="node-shape kind-service emphasis-normal role-service" data-evidence="940-990,1009-1014" x="595.0" y="240.0" width="190.0" height="105.0" rx="7" />
<text class="node-label" x="690.0" y="267.0">oauth2-proxy</text>
<line class="node-detail-divider" x1="609.0" y1="288.0" x2="771.0" y2="288.0" />
<text class="node-detail" x="611.0" y="305.0">edge-proxy client</text>
<text class="node-detail" x="611.0" y="321.0">minimal cookie session</text>
<text class="node-detail" x="611.0" y="337.0">returns user · email</text>
<rect class="node-shape kind-service emphasis-normal role-service" data-evidence="978-1028,1049-1054" x="765.5" y="81.0" width="188.0" height="105.0" rx="7" />
<text class="node-label" x="859.5" y="108.0">oauth2-proxy</text>
<line class="node-detail-divider" x1="779.5" y1="129.0" x2="939.5" y2="129.0" />
<text class="node-detail" x="781.5" y="146.0">edge-proxy client</text>
<text class="node-detail" x="781.5" y="162.0">minimal cookie session</text>
<text class="node-detail" x="781.5" y="178.0">returns user · email</text>
</g>
<g id="node-spring-upstream">
<rect class="node-shape kind-service emphasis-normal role-sink" data-evidence="1057-1077,1105-1118" x="755.0" y="258.0" width="209.0" height="122.0" rx="7" />
<text class="node-label" x="859.5" y="285.0">Spring upstream</text>
<line class="node-detail-divider" x1="769.0" y1="306.0" x2="950.0" y2="306.0" />
<text class="node-detail" x="771.0" y="323.0">X-Auth-Request-User</text>
<text class="node-detail" x="771.0" y="339.0">X-Auth-Request-Email</text>
<text class="node-detail" x="771.0" y="355.0">X-Internal-Auth-Token</text>
<text class="node-detail" x="771.0" y="371.0">/edge/me controller check</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

@@ -1,16 +0,0 @@
# 패턴 이동은 credential contract의 소유권 전환이다
## Alternative text
AP1에서 AP2, AP2에서 AP3, AP3에서 AP4, AP4에서 AP3로 이동할 때 호출 계약, 소유권, 브라우저 계약, 운영 책임과 전환 성격을 같은 다섯 축으로 비교한 네 항목.
## Long description
네 항목을 호출 계약, 소유권, 브라우저 계약, 운영 책임, 전환 성격의 다섯 축으로 비교한다. AP1에서 AP2는 Resource Server의 Bearer 계약을 유지하면서 OAuth callback을 mediator로 옮긴다. AP2에서 AP3는 browser의 access endpoint와 direct API 호출을 없애고 UI API를 BFF 계약으로 바꾼다. AP3에서 AP4는 application-owned session과 orchestration을 edge-owned session과 identity projection으로 전환한다. AP4에서 AP3로 되돌아가는 선택도 별도 항목으로 두어 번호 순서나 성숙도 상승을 암시하지 않는다.
## Elements and evidence
- **AP1 → AP2** (concept): Bearer API는 유지하되 OAuth callback과 browser-facing session·access endpoint의 책임을 mediator 쪽으로 옮기는 전환. Evidence: L1285L1285.
- **AP2 → AP3** (concept): Browser access handoff와 direct API 호출을 제거하고 UI의 API 계약과 관련 방어를 BFF가 소유하게 하는 전환. Evidence: L1287L1287.
- **AP3 → AP4** (concept): Application-owned session과 API orchestration을 edge-owned session과 identity projection으로 바꾸고 세밀한 인가를 재배치하는 전환. Evidence: L1289L1289.
- **AP4 → AP3** (concept): Upstream의 claim과 workflow 요구가 커질 때 header contract를 계속 넓히지 않고 책임을 BFF로 되돌리는 역방향 전환. Evidence: L1291L1291.
@@ -1,15 +0,0 @@
# 패턴 이동은 credential contract의 소유권 전환이다
# Question: AP1→AP2, AP2→AP3, AP3→AP4, AP4→AP3 이동에서 호출 계약과 책임 소유자는 각각 어떻게 바뀌는가?
direction: right
n0: "AP1 → AP2" {
shape: rectangle
}
n1: "AP2 → AP3" {
shape: rectangle
}
n2: "AP3 → AP4" {
shape: rectangle
}
n3: "AP4 → AP3" {
shape: rectangle
}
@@ -1,9 +0,0 @@
digraph techviz {
graph [rankdir=LR, splines=ortho, nodesep=0.55, ranksep=0.85];
node [fontname=Helvetica, fontsize=11, margin="0.18,0.12", style="rounded,filled", fillcolor=white, color="#2d4357", penwidth=1.5];
edge [fontname=Helvetica, fontsize=10, color="#364b5f", penwidth=1.4, arrowsize=0.75];
n0 [label="AP1 → AP2", shape=box, style="rounded,filled"];
n1 [label="AP2 → AP3", shape=box, style="rounded,filled"];
n2 [label="AP3 → AP4", shape=box, style="rounded,filled"];
n3 [label="AP4 → AP3", shape=box, style="rounded,filled"];
}
@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
<diagram id="credential-contract-migration" name="패턴 이동은 credential contract의 소유권 전환이다">
<mxGraphModel dx="1138" dy="300" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1138" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_ap1-to-ap2" value="AP1 → AP2&lt;br/&gt;호출 · Bearer API 유지&lt;br/&gt;소유 · callback → mediator&lt;br/&gt;브라우저 · access API + session&lt;br/&gt;운영 · CORS → AP2 UI&lt;br/&gt;성격 · callback 재배치" tooltip="Bearer API는 유지하되 OAuth callback과 browser-facing session·access endpoint의 책임을 mediator 쪽으로 옮기는 전환. | Evidence: L1285-L1285" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="45.0" y="45.0" width="223.0" height="139.0" as="geometry"/>
</mxCell>
<mxCell id="n_ap2-to-ap3" value="AP2 → AP3&lt;br/&gt;호출 · direct API → /bff/**&lt;br/&gt;소유 · UI API → BFF&lt;br/&gt;브라우저 · /token/access 제거&lt;br/&gt;운영 · error mapping + CSRF&lt;br/&gt;성격 · direct path 제거" tooltip="Browser access handoff와 direct API 호출을 제거하고 UI의 API 계약과 관련 방어를 BFF가 소유하게 하는 전환. | Evidence: L1287-L1287" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="320.0" y="45.0" width="223.0" height="139.0" as="geometry"/>
</mxCell>
<mxCell id="n_ap3-to-ap4" value="AP3 → AP4&lt;br/&gt;호출 · API 조합 → identity&lt;br/&gt;소유 · app session → edge&lt;br/&gt;브라우저 · identity projection&lt;br/&gt;운영 · 인가 → upstream/policy&lt;br/&gt;성격 · upgrade 아님" tooltip="Application-owned session과 API orchestration을 edge-owned session과 identity projection으로 바꾸고 세밀한 인가를 재배치하는 전환. | Evidence: L1289-L1289" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="595.0" y="45.0" width="223.0" height="139.0" as="geometry"/>
</mxCell>
<mxCell id="n_ap4-to-ap3" value="AP4 → AP3&lt;br/&gt;호출 · headers → BFF workflow&lt;br/&gt;소유 · claims/workflow → BFF&lt;br/&gt;브라우저 · app workflow&lt;br/&gt;운영 · state + verification&lt;br/&gt;성격 · 역방향 가능" tooltip="Upstream의 claim과 workflow 요구가 커질 때 header contract를 계속 넓히지 않고 책임을 BFF로 되돌리는 역방향 전환. | Evidence: L1291-L1291" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="870.0" y="45.0" width="223.0" height="139.0" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -1,289 +0,0 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "node-ap1-to-ap2",
"type": "rectangle",
"x": 45.0,
"y": 45.0,
"width": 223.0,
"height": 139.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1557249645,
"version": 1,
"versionNonce": 294499605,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-ap1-to-ap2",
"type": "text",
"x": 55.0,
"y": 55.0,
"width": 203.0,
"height": 119.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1702265635,
"version": 1,
"versionNonce": 60175260,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "AP1 → AP2\n호출 · Bearer API 유지\n소유 · callback → mediator\n브라우저 · access API + session\n운영 · CORS → AP2 UI\n성격 · callback 재배치",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "AP1 → AP2\n호출 · Bearer API 유지\n소유 · callback → mediator\n브라우저 · access API + session\n운영 · CORS → AP2 UI\n성격 · callback 재배치",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-ap2-to-ap3",
"type": "rectangle",
"x": 320.0,
"y": 45.0,
"width": 223.0,
"height": 139.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1331377000,
"version": 1,
"versionNonce": 108260129,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-ap2-to-ap3",
"type": "text",
"x": 330.0,
"y": 55.0,
"width": 203.0,
"height": 119.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 770033263,
"version": 1,
"versionNonce": 1669588630,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "AP2 → AP3\n호출 · direct API → /bff/**\n소유 · UI API → BFF\n브라우저 · /token/access 제거\n운영 · error mapping + CSRF\n성격 · direct path 제거",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "AP2 → AP3\n호출 · direct API → /bff/**\n소유 · UI API → BFF\n브라우저 · /token/access 제거\n운영 · error mapping + CSRF\n성격 · direct path 제거",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-ap3-to-ap4",
"type": "rectangle",
"x": 595.0,
"y": 45.0,
"width": 223.0,
"height": 139.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 372334166,
"version": 1,
"versionNonce": 184792543,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-ap3-to-ap4",
"type": "text",
"x": 605.0,
"y": 55.0,
"width": 203.0,
"height": 119.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1382057023,
"version": 1,
"versionNonce": 693403148,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "AP3 → AP4\n호출 · API 조합 → identity\n소유 · app session → edge\n브라우저 · identity projection\n운영 · 인가 → upstream/policy\n성격 · upgrade 아님",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "AP3 → AP4\n호출 · API 조합 → identity\n소유 · app session → edge\n브라우저 · identity projection\n운영 · 인가 → upstream/policy\n성격 · upgrade 아님",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-ap4-to-ap3",
"type": "rectangle",
"x": 870.0,
"y": 45.0,
"width": 223.0,
"height": 139.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 91674431,
"version": 1,
"versionNonce": 1299751764,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-ap4-to-ap3",
"type": "text",
"x": 880.0,
"y": 55.0,
"width": 203.0,
"height": 119.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1921524377,
"version": 1,
"versionNonce": 81557497,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "AP4 → AP3\n호출 · headers → BFF workflow\n소유 · claims/workflow → BFF\n브라우저 · app workflow\n운영 · state + verification\n성격 · 역방향 가능",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "AP4 → AP3\n호출 · headers → BFF workflow\n소유 · claims/workflow → BFF\n브라우저 · app workflow\n운영 · state + verification\n성격 · 역방향 가능",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -1,32 +0,0 @@
{
"harness_version": "0.2.0",
"spec_id": "credential-contract-migration",
"spec_version": "1.1",
"spec_sha256": "b075725369a92edd8b788f1c2bf6eb695cd07d0b78c3333ea8275631f3da1297",
"source_context": {
"document": "document.md",
"document_sha256": "df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371",
"anchor": {
"kind": "marker",
"value": "credential-contract-migration",
"line": 1293
}
},
"outputs": [
"credential-contract-migration.svg",
"credential-contract-migration.drawio",
"credential-contract-migration.mmd",
"credential-contract-migration.d2",
"credential-contract-migration.dot",
"credential-contract-migration.excalidraw",
"credential-contract-migration.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "comparison",
"reference_ids": [
"contract-comparison"
],
"diagram_only": true
}
@@ -1,7 +0,0 @@
%% 패턴 이동은 credential contract의 소유권 전환이다
%% question: AP1→AP2, AP2→AP3, AP3→AP4, AP4→AP3 이동에서 호출 계약과 책임 소유자는 각각 어떻게 바뀌는가?
flowchart LR
n0["AP1 → AP2"]
n1["AP2 → AP3"]
n2["AP3 → AP4"]
n3["AP4 → AP3"]
@@ -1,93 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1138" height="300" viewBox="0 0 1138 300" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">패턴 이동은 credential contract의 소유권 전환이다</title>
<desc id="diagram-description">네 항목을 호출 계약, 소유권, 브라우저 계약, 운영 책임, 전환 성격의 다섯 축으로 비교한다. AP1에서 AP2는 Resource Server의 Bearer 계약을 유지하면서 OAuth callback을 mediator로 옮긴다. AP2에서 AP3는 browser의 access endpoint와 direct API 호출을 없애고 UI API를 BFF 계약으로 바꾼다. AP3에서 AP4는 application-owned session과 orchestration을 edge-owned session과 identity projection으로 전환한다. AP4에서 AP3로 되돌아가는 선택도 별도 항목으로 두어 번호 순서나 성숙도 상승을 암시하지 않는다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;credential-contract-migration&quot;,&quot;profile&quot;:&quot;comparison&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;document.md&quot;,&quot;document_sha256&quot;:&quot;df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;marker&quot;,&quot;value&quot;:&quot;credential-contract-migration&quot;,&quot;line&quot;:1293}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
.edge.style-dotted { stroke-dasharray: 2 5; }
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.emphasis-muted { stroke: #9ca3af; }
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
.edge-label-bg { fill: #ffffff; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
.controller-led { fill: #4b5563; }
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
.node-detail { font-size: 11px; fill: #374151; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="1138" height="300" />
<g id="node-ap1-to-ap2">
<rect class="node-shape kind-concept emphasis-normal role-contract" data-evidence="1285-1285" x="45.0" y="45.0" width="223.0" height="139.0" rx="7" />
<text class="node-label" x="156.5" y="72.5">AP1 → AP2</text>
<line class="node-detail-divider" x1="59.0" y1="93.5" x2="254.0" y2="93.5" />
<text class="node-detail" x="61.0" y="110.5">호출 · Bearer API 유지</text>
<text class="node-detail" x="61.0" y="126.5">소유 · callback → mediator</text>
<text class="node-detail" x="61.0" y="142.5">브라우저 · access API + session</text>
<text class="node-detail" x="61.0" y="158.5">운영 · CORS → AP2 UI</text>
<text class="node-detail" x="61.0" y="174.5">성격 · callback 재배치</text>
</g>
<g id="node-ap2-to-ap3">
<rect class="node-shape kind-concept emphasis-normal role-contract" data-evidence="1287-1287" x="320.0" y="45.0" width="223.0" height="139.0" rx="7" />
<text class="node-label" x="431.5" y="72.5">AP2 → AP3</text>
<line class="node-detail-divider" x1="334.0" y1="93.5" x2="529.0" y2="93.5" />
<text class="node-detail" x="336.0" y="110.5">호출 · direct API → /bff/**</text>
<text class="node-detail" x="336.0" y="126.5">소유 · UI API → BFF</text>
<text class="node-detail" x="336.0" y="142.5">브라우저 · /token/access 제거</text>
<text class="node-detail" x="336.0" y="158.5">운영 · error mapping + CSRF</text>
<text class="node-detail" x="336.0" y="174.5">성격 · direct path 제거</text>
</g>
<g id="node-ap3-to-ap4">
<rect class="node-shape kind-concept emphasis-normal role-contract" data-evidence="1289-1289" x="595.0" y="45.0" width="223.0" height="139.0" rx="7" />
<text class="node-label" x="706.5" y="72.5">AP3 → AP4</text>
<line class="node-detail-divider" x1="609.0" y1="93.5" x2="804.0" y2="93.5" />
<text class="node-detail" x="611.0" y="110.5">호출 · API 조합 → identity</text>
<text class="node-detail" x="611.0" y="126.5">소유 · app session → edge</text>
<text class="node-detail" x="611.0" y="142.5">브라우저 · identity projection</text>
<text class="node-detail" x="611.0" y="158.5">운영 · 인가 → upstream/policy</text>
<text class="node-detail" x="611.0" y="174.5">성격 · upgrade 아님</text>
</g>
<g id="node-ap4-to-ap3">
<rect class="node-shape kind-concept emphasis-normal role-contract" data-evidence="1291-1291" x="870.0" y="45.0" width="223.0" height="139.0" rx="7" />
<text class="node-label" x="981.5" y="72.5">AP4 → AP3</text>
<line class="node-detail-divider" x1="884.0" y1="93.5" x2="1079.0" y2="93.5" />
<text class="node-detail" x="886.0" y="110.5">호출 · headers → BFF workflow</text>
<text class="node-detail" x="886.0" y="126.5">소유 · claims/workflow → BFF</text>
<text class="node-detail" x="886.0" y="142.5">브라우저 · app workflow</text>
<text class="node-detail" x="886.0" y="158.5">운영 · state + verification</text>
<text class="node-detail" x="886.0" y="174.5">성격 · 역방향 가능</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.5 KiB

@@ -1,16 +0,0 @@
# Credential은 사라지지 않고 owner와 형태가 바뀐다
## Alternative text
AP1부터 AP4까지 OAuth credential 소유자, 브라우저 credential, 보관 모델과 현재 입증된 운영 범위를 같은 네 축으로 정렬한 비교 다이어그램.
## Long description
왼쪽부터 AP1, AP2, AP3, AP4를 읽는다. 각 항목은 OAuth credential 소유자, 브라우저에 남는 애플리케이션 credential, 보관 모델, shared durability나 replica 운영에 관한 현재 근거를 같은 순서로 제시한다. AP1의 access·refresh·ID token은 실행 중 JavaScript memory에 있고 persistent Web Storage 복사본만 줄인다. AP2는 refresh token을 mediator가 소유하고 server session 식별 cookie를 사용하지만 shared durable store는 입증되지 않았다. AP3는 refresh token을 BFF가 소유하며 브라우저에는 HttpOnly session과 readable XSRF token이 남는다. AP4는 minimal client-side proxy session을 사용하고 replica cookie secret 공유와 rotation은 검증되지 않았다.
## Elements and evidence
- **AP1 · browser memory** (concept): AP1은 OAuth token을 실행 중 JavaScript memory에 두며 memory-only 선택은 persistent script-readable 복사본을 줄인다. Evidence: L53L53, L66L66.
- **AP2 · mediator custody** (concept): AP2는 refresh token owner를 mediator로 옮기고 server-side login state를 식별하는 cookie를 사용하지만 shared durable persistence는 입증되지 않았다. Evidence: L53L54, L72L72.
- **AP3 · BFF/session** (concept): AP3는 OAuth token을 JavaScript에 주지 않고 BFF가 refresh token을 소유하지만 브라우저에는 session과 CSRF용 readable token이 남는다. Evidence: L53L54, L64L64, L72L72.
- **AP4 · proxy session** (concept): AP4는 minimal client-side proxy session을 사용하며 JavaScript는 OAuth token을 받지 않지만 replica 간 cookie secret 운영은 검증되지 않았다. Evidence: L55L55, L64L64, L72L72.
@@ -1,15 +0,0 @@
# Credential은 사라지지 않고 owner와 형태가 바뀐다
# Question: 네 패턴은 애플리케이션 credential을 어느 경계에 보관하며 브라우저 상태와 운영 근거에는 무엇이 남는가?
direction: right
n0: "AP1 · browser memory" {
shape: rectangle
}
n1: "AP2 · mediator custody" {
shape: rectangle
}
n2: "AP3 · BFF/session" {
shape: rectangle
}
n3: "AP4 · proxy session" {
shape: rectangle
}
@@ -1,9 +0,0 @@
digraph techviz {
graph [rankdir=LR, splines=ortho, nodesep=0.55, ranksep=0.85];
node [fontname=Helvetica, fontsize=11, margin="0.18,0.12", style="rounded,filled", fillcolor=white, color="#2d4357", penwidth=1.5];
edge [fontname=Helvetica, fontsize=10, color="#364b5f", penwidth=1.4, arrowsize=0.75];
n0 [label="AP1 · browser memory", shape=box, style="rounded,filled"];
n1 [label="AP2 · mediator custody", shape=box, style="rounded,filled"];
n2 [label="AP3 · BFF/session", shape=box, style="rounded,filled"];
n3 [label="AP4 · proxy session", shape=box, style="rounded,filled"];
}
@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
<diagram id="credential-custody-map" name="Credential은 사라지지 않고 owner와 형태가 바뀐다">
<mxGraphModel dx="1138" dy="300" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1138" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_ap1-browser-memory" value="AP1 · browser memory&lt;br/&gt;OAuth: token 3종 → JS&lt;br/&gt;브라우저: 실행 중 JS가 읽음&lt;br/&gt;보관: runtime memory&lt;br/&gt;운영: 영속 Web Storage 없음" tooltip="AP1은 OAuth token을 실행 중 JavaScript memory에 두며 memory-only 선택은 persistent script-readable 복사본을 줄인다. | Evidence: L53-L53, L66-L66" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#d97706;fillColor=#fffdf5;" vertex="1" parent="1">
<mxGeometry x="45.0" y="45.0" width="223.0" height="122.0" as="geometry"/>
</mxCell>
<mxCell id="n_ap2-mediator-custody" value="AP2 · mediator custody&lt;br/&gt;OAuth: refresh → mediator&lt;br/&gt;브라우저: session cookie&lt;br/&gt;보관: server-side state&lt;br/&gt;운영: shared store 미입증" tooltip="AP2는 refresh token owner를 mediator로 옮기고 server-side login state를 식별하는 cookie를 사용하지만 shared durable persistence는 입증되지 않았다. | Evidence: L53-L54, L72-L72" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="320.0" y="45.0" width="223.0" height="122.0" as="geometry"/>
</mxCell>
<mxCell id="n_ap3-bff-custody" value="AP3 · BFF/session&lt;br/&gt;OAuth: refresh → BFF&lt;br/&gt;브라우저: HttpOnly session&lt;br/&gt;보관: session + readable XSRF&lt;br/&gt;운영: shared store 미입증" tooltip="AP3는 OAuth token을 JavaScript에 주지 않고 BFF가 refresh token을 소유하지만 브라우저에는 session과 CSRF용 readable token이 남는다. | Evidence: L53-L54, L64-L64, L72-L72" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="595.0" y="45.0" width="223.0" height="122.0" as="geometry"/>
</mxCell>
<mxCell id="n_ap4-proxy-session" value="AP4 · proxy session&lt;br/&gt;OAuth: JS 미수신&lt;br/&gt;브라우저: proxy session&lt;br/&gt;보관: minimal client-side&lt;br/&gt;운영: secret 공유·회전 미검증" tooltip="AP4는 minimal client-side proxy session을 사용하며 JavaScript는 OAuth token을 받지 않지만 replica 간 cookie secret 운영은 검증되지 않았다. | Evidence: L55-L55, L64-L64, L72-L72" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="870.0" y="45.0" width="223.0" height="122.0" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -1,289 +0,0 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "node-ap1-browser-memory",
"type": "rectangle",
"x": 45.0,
"y": 45.0,
"width": 223.0,
"height": 122.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 222720570,
"version": 1,
"versionNonce": 40282148,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-ap1-browser-memory",
"type": "text",
"x": 55.0,
"y": 55.0,
"width": 203.0,
"height": 102.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 983051494,
"version": 1,
"versionNonce": 1811623656,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "AP1 · browser memory\nOAuth: token 3종 → JS\n브라우저: 실행 중 JS가 읽음\n보관: runtime memory\n운영: 영속 Web Storage 없음",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "AP1 · browser memory\nOAuth: token 3종 → JS\n브라우저: 실행 중 JS가 읽음\n보관: runtime memory\n운영: 영속 Web Storage 없음",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-ap2-mediator-custody",
"type": "rectangle",
"x": 320.0,
"y": 45.0,
"width": 223.0,
"height": 122.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1325947112,
"version": 1,
"versionNonce": 219900650,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-ap2-mediator-custody",
"type": "text",
"x": 330.0,
"y": 55.0,
"width": 203.0,
"height": 102.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1709863446,
"version": 1,
"versionNonce": 31489928,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "AP2 · mediator custody\nOAuth: refresh → mediator\n브라우저: session cookie\n보관: server-side state\n운영: shared store 미입증",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "AP2 · mediator custody\nOAuth: refresh → mediator\n브라우저: session cookie\n보관: server-side state\n운영: shared store 미입증",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-ap3-bff-custody",
"type": "rectangle",
"x": 595.0,
"y": 45.0,
"width": 223.0,
"height": 122.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1229486250,
"version": 1,
"versionNonce": 1938967200,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-ap3-bff-custody",
"type": "text",
"x": 605.0,
"y": 55.0,
"width": 203.0,
"height": 102.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1526462817,
"version": 1,
"versionNonce": 1290302415,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "AP3 · BFF/session\nOAuth: refresh → BFF\n브라우저: HttpOnly session\n보관: session + readable XSRF\n운영: shared store 미입증",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "AP3 · BFF/session\nOAuth: refresh → BFF\n브라우저: HttpOnly session\n보관: session + readable XSRF\n운영: shared store 미입증",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-ap4-proxy-session",
"type": "rectangle",
"x": 870.0,
"y": 45.0,
"width": 223.0,
"height": 122.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 756651226,
"version": 1,
"versionNonce": 818671235,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-ap4-proxy-session",
"type": "text",
"x": 880.0,
"y": 55.0,
"width": 203.0,
"height": 102.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1227962233,
"version": 1,
"versionNonce": 883345119,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "AP4 · proxy session\nOAuth: JS 미수신\n브라우저: proxy session\n보관: minimal client-side\n운영: secret 공유·회전 미검증",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "AP4 · proxy session\nOAuth: JS 미수신\n브라우저: proxy session\n보관: minimal client-side\n운영: secret 공유·회전 미검증",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -1,32 +0,0 @@
{
"harness_version": "0.2.0",
"spec_id": "credential-custody-map",
"spec_version": "1.1",
"spec_sha256": "f0c0866b8d1a0d6a485c88d4514b35e935a10b9d6d29e446b4ada7b1ee2e5a40",
"source_context": {
"document": "document.md",
"document_sha256": "df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371",
"anchor": {
"kind": "marker",
"value": "credential-custody-map",
"line": 68
}
},
"outputs": [
"credential-custody-map.svg",
"credential-custody-map.drawio",
"credential-custody-map.mmd",
"credential-custody-map.d2",
"credential-custody-map.dot",
"credential-custody-map.excalidraw",
"credential-custody-map.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "comparison",
"reference_ids": [
"contract-comparison"
],
"diagram_only": true
}
@@ -1,7 +0,0 @@
%% Credential은 사라지지 않고 owner와 형태가 바뀐다
%% question: 네 패턴은 애플리케이션 credential을 어느 경계에 보관하며 브라우저 상태와 운영 근거에는 무엇이 남는가?
flowchart LR
n0["AP1 · browser memory"]
n1["AP2 · mediator custody"]
n2["AP3 · BFF/session"]
n3["AP4 · proxy session"]
@@ -1,89 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1138" height="300" viewBox="0 0 1138 300" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">Credential은 사라지지 않고 owner와 형태가 바뀐다</title>
<desc id="diagram-description">왼쪽부터 AP1, AP2, AP3, AP4를 읽는다. 각 항목은 OAuth credential 소유자, 브라우저에 남는 애플리케이션 credential, 보관 모델, shared durability나 replica 운영에 관한 현재 근거를 같은 순서로 제시한다. AP1의 access·refresh·ID token은 실행 중 JavaScript memory에 있고 persistent Web Storage 복사본만 줄인다. AP2는 refresh token을 mediator가 소유하고 server session 식별 cookie를 사용하지만 shared durable store는 입증되지 않았다. AP3는 refresh token을 BFF가 소유하며 브라우저에는 HttpOnly session과 readable XSRF token이 남는다. AP4는 minimal client-side proxy session을 사용하고 replica cookie secret 공유와 rotation은 검증되지 않았다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;credential-custody-map&quot;,&quot;profile&quot;:&quot;comparison&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;document.md&quot;,&quot;document_sha256&quot;:&quot;df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;marker&quot;,&quot;value&quot;:&quot;credential-custody-map&quot;,&quot;line&quot;:68}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
.edge.style-dotted { stroke-dasharray: 2 5; }
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.emphasis-muted { stroke: #9ca3af; }
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
.edge-label-bg { fill: #ffffff; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
.controller-led { fill: #4b5563; }
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
.node-detail { font-size: 11px; fill: #374151; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="1138" height="300" />
<g id="node-ap1-browser-memory">
<rect class="node-shape kind-concept emphasis-warning role-option" data-evidence="53-53,66-66" x="45.0" y="45.0" width="223.0" height="122.0" rx="7" />
<text class="node-label" x="156.5" y="72.0">AP1 · browser memory</text>
<line class="node-detail-divider" x1="59.0" y1="93.0" x2="254.0" y2="93.0" />
<text class="node-detail" x="61.0" y="110.0">OAuth: token 3종 → JS</text>
<text class="node-detail" x="61.0" y="126.0">브라우저: 실행 중 JS가 읽음</text>
<text class="node-detail" x="61.0" y="142.0">보관: runtime memory</text>
<text class="node-detail" x="61.0" y="158.0">운영: 영속 Web Storage 없음</text>
</g>
<g id="node-ap2-mediator-custody">
<rect class="node-shape kind-concept emphasis-normal role-option" data-evidence="53-54,72-72" x="320.0" y="45.0" width="223.0" height="122.0" rx="7" />
<text class="node-label" x="431.5" y="72.0">AP2 · mediator custody</text>
<line class="node-detail-divider" x1="334.0" y1="93.0" x2="529.0" y2="93.0" />
<text class="node-detail" x="336.0" y="110.0">OAuth: refresh → mediator</text>
<text class="node-detail" x="336.0" y="126.0">브라우저: session cookie</text>
<text class="node-detail" x="336.0" y="142.0">보관: server-side state</text>
<text class="node-detail" x="336.0" y="158.0">운영: shared store 미입증</text>
</g>
<g id="node-ap3-bff-custody">
<rect class="node-shape kind-concept emphasis-primary role-option" data-evidence="53-54,64-64,72-72" x="595.0" y="45.0" width="223.0" height="122.0" rx="7" />
<text class="node-label" x="706.5" y="72.0">AP3 · BFF/session</text>
<line class="node-detail-divider" x1="609.0" y1="93.0" x2="804.0" y2="93.0" />
<text class="node-detail" x="611.0" y="110.0">OAuth: refresh → BFF</text>
<text class="node-detail" x="611.0" y="126.0">브라우저: HttpOnly session</text>
<text class="node-detail" x="611.0" y="142.0">보관: session + readable XSRF</text>
<text class="node-detail" x="611.0" y="158.0">운영: shared store 미입증</text>
</g>
<g id="node-ap4-proxy-session">
<rect class="node-shape kind-concept emphasis-normal role-option" data-evidence="55-55,64-64,72-72" x="870.0" y="45.0" width="223.0" height="122.0" rx="7" />
<text class="node-label" x="981.5" y="72.0">AP4 · proxy session</text>
<line class="node-detail-divider" x1="884.0" y1="93.0" x2="1079.0" y2="93.0" />
<text class="node-detail" x="886.0" y="110.0">OAuth: JS 미수신</text>
<text class="node-detail" x="886.0" y="126.0">브라우저: proxy session</text>
<text class="node-detail" x="886.0" y="142.0">보관: minimal client-side</text>
<text class="node-detail" x="886.0" y="158.0">운영: secret 공유·회전 미검증</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.4 KiB

@@ -1,16 +0,0 @@
# 로그인 후 요청에서 달라지는 네 인증 경계
## Alternative text
AP1, AP2, AP3, AP4의 브라우저 입력, 중간 변환, 보호 자원 credential과 브라우저 출력을 같은 네 축으로 비교한 다이어그램.
## Long description
왼쪽부터 AP1 SPA direct, AP2 token mediator, AP3 BFF, AP4 edge forward-auth를 읽는다. 각 항목은 브라우저가 보내는 입력, 중간 계층의 변환, 보호 자원이 실제로 받는 credential, 브라우저가 받는 출력을 같은 순서로 보여 준다. AP1과 AP2는 브라우저가 Bearer JWT로 API를 직접 호출하고, AP3는 BFF가 Bearer JWT를 조립하며, AP4는 Nginx가 인증 결과를 identity header와 internal token으로 바꾼다.
## Elements and evidence
- **AP1 · SPA direct** (concept): 브라우저가 보유한 access token으로 Resource Server를 직접 호출하는 패턴. Evidence: L90L101, L107L109.
- **AP2 · token mediator** (concept): Mediator가 refresh token을 보관하지만 access token을 브라우저에 전달해 직접 API 호출을 유지하는 패턴. Evidence: L90L101, L107L110.
- **AP3 · BFF** (concept): 브라우저 session을 BFF가 server-held access token 기반의 downstream Bearer 요청으로 바꾸는 패턴. Evidence: L90L101, L107L111.
- **AP4 · edge forward-auth** (concept): Nginx와 oauth2-proxy가 proxy session을 검증하고 allowlisted identity header와 internal token을 upstream에 전달하는 패턴. Evidence: L90L101, L107L112.
@@ -1,15 +0,0 @@
# 로그인 후 요청에서 달라지는 네 인증 경계
# Question: AP1부터 AP4까지 브라우저 입력, credential 변환, 보호 자원 입력과 최종 출력은 어떻게 다른가?
direction: right
n0: "AP1 · SPA direct" {
shape: rectangle
}
n1: "AP2 · token mediator" {
shape: rectangle
}
n2: "AP3 · BFF" {
shape: rectangle
}
n3: "AP4 · edge forward-auth" {
shape: rectangle
}
@@ -1,9 +0,0 @@
digraph techviz {
graph [rankdir=LR, splines=ortho, nodesep=0.55, ranksep=0.85];
node [fontname=Helvetica, fontsize=11, margin="0.18,0.12", style="rounded,filled", fillcolor=white, color="#2d4357", penwidth=1.5];
edge [fontname=Helvetica, fontsize=10, color="#364b5f", penwidth=1.4, arrowsize=0.75];
n0 [label="AP1 · SPA direct", shape=box, style="rounded,filled"];
n1 [label="AP2 · token mediator", shape=box, style="rounded,filled"];
n2 [label="AP3 · BFF", shape=box, style="rounded,filled"];
n3 [label="AP4 · edge forward-auth", shape=box, style="rounded,filled"];
}
@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<mxfile host="app.diagrams.net" modified="2026-07-23T00:00:00.000Z" agent="techviz-harness" version="24.7.17" type="device">
<diagram id="four-pattern-request-boundaries" name="로그인 후 요청에서 달라지는 네 인증 경계">
<mxGraphModel dx="1126" dy="300" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1126" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_ap1" value="AP1 · SPA direct&lt;br/&gt;입력 · Bearer access token&lt;br/&gt;변환 · 없음&lt;br/&gt;자원 · 동일 Bearer JWT&lt;br/&gt;출력 · /api/me JSON" tooltip="브라우저가 보유한 access token으로 Resource Server를 직접 호출하는 패턴. | Evidence: L90-L101, L107-L109" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="45.0" y="45.0" width="220.0" height="142.0" as="geometry"/>
</mxCell>
<mxCell id="n_ap2" value="AP2 · token mediator&lt;br/&gt;입력 · session 뒤 Bearer&lt;br/&gt;변환 · access JSON 반환&lt;br/&gt;자원 · browser-created JWT&lt;br/&gt;출력 · token JSON + /api/me" tooltip="Mediator가 refresh token을 보관하지만 access token을 브라우저에 전달해 직접 API 호출을 유지하는 패턴. | Evidence: L90-L101, L107-L110" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="317.0" y="45.0" width="220.0" height="142.0" as="geometry"/>
</mxCell>
<mxCell id="n_ap3" value="AP3 · BFF&lt;br/&gt;입력 · AP3_SESSION&lt;br/&gt;변환 · BFF Bearer 생성&lt;br/&gt;자원 · BFF Bearer JWT&lt;br/&gt;출력 · 중계된 JSON" tooltip="브라우저 session을 BFF가 server-held access token 기반의 downstream Bearer 요청으로 바꾸는 패턴. | Evidence: L90-L101, L107-L111" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="589.0" y="45.0" width="220.0" height="142.0" as="geometry"/>
</mxCell>
<mxCell id="n_ap4" value="AP4 · edge forward-auth&lt;br/&gt;입력 · AP4_SESSION&lt;br/&gt;변환 · auth 결과 투영&lt;br/&gt;자원 · identity + internal&lt;br/&gt;출력 · /edge/me JSON" tooltip="Nginx와 oauth2-proxy가 proxy session을 검증하고 allowlisted identity header와 internal token을 upstream에 전달하는 패턴. | Evidence: L90-L101, L107-L112" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="861.0" y="45.0" width="220.0" height="142.0" as="geometry"/>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -1,289 +0,0 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "node-ap1",
"type": "rectangle",
"x": 45.0,
"y": 45.0,
"width": 220.0,
"height": 142.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 725805455,
"version": 1,
"versionNonce": 16032614,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-ap1",
"type": "text",
"x": 55.0,
"y": 55.0,
"width": 200.0,
"height": 122.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1345418364,
"version": 1,
"versionNonce": 956861127,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "AP1 · SPA direct\n입력 · Bearer access token\n변환 · 없음\n자원 · 동일 Bearer JWT\n출력 · /api/me JSON",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "AP1 · SPA direct\n입력 · Bearer access token\n변환 · 없음\n자원 · 동일 Bearer JWT\n출력 · /api/me JSON",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-ap2",
"type": "rectangle",
"x": 317.0,
"y": 45.0,
"width": 220.0,
"height": 142.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1770767914,
"version": 1,
"versionNonce": 437466311,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-ap2",
"type": "text",
"x": 327.0,
"y": 55.0,
"width": 200.0,
"height": 122.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1794487571,
"version": 1,
"versionNonce": 1211856887,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "AP2 · token mediator\n입력 · session 뒤 Bearer\n변환 · access JSON 반환\n자원 · browser-created JWT\n출력 · token JSON + /api/me",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "AP2 · token mediator\n입력 · session 뒤 Bearer\n변환 · access JSON 반환\n자원 · browser-created JWT\n출력 · token JSON + /api/me",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-ap3",
"type": "rectangle",
"x": 589.0,
"y": 45.0,
"width": 220.0,
"height": 142.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 134610251,
"version": 1,
"versionNonce": 1259056791,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-ap3",
"type": "text",
"x": 599.0,
"y": 55.0,
"width": 200.0,
"height": 122.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1719537796,
"version": 1,
"versionNonce": 237559748,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "AP3 · BFF\n입력 · AP3_SESSION\n변환 · BFF Bearer 생성\n자원 · BFF Bearer JWT\n출력 · 중계된 JSON",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "AP3 · BFF\n입력 · AP3_SESSION\n변환 · BFF Bearer 생성\n자원 · BFF Bearer JWT\n출력 · 중계된 JSON",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-ap4",
"type": "rectangle",
"x": 861.0,
"y": 45.0,
"width": 220.0,
"height": 142.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1479126963,
"version": 1,
"versionNonce": 1497424249,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-ap4",
"type": "text",
"x": 871.0,
"y": 55.0,
"width": 200.0,
"height": 122.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "solid",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1313196111,
"version": 1,
"versionNonce": 1204290805,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "AP4 · edge forward-auth\n입력 · AP4_SESSION\n변환 · auth 결과 투영\n자원 · identity + internal\n출력 · /edge/me JSON",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "AP4 · edge forward-auth\n입력 · AP4_SESSION\n변환 · auth 결과 투영\n자원 · identity + internal\n출력 · /edge/me JSON",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -1,32 +0,0 @@
{
"harness_version": "0.2.0",
"spec_id": "four-pattern-request-boundaries",
"spec_version": "1.1",
"spec_sha256": "11344b11427ef0a66e5dc29869606f51063afdb71489fc22f7ad52d7c3458e79",
"source_context": {
"document": "document.md",
"document_sha256": "df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371",
"anchor": {
"kind": "marker",
"value": "four-pattern-request-boundaries",
"line": 114
}
},
"outputs": [
"four-pattern-request-boundaries.svg",
"four-pattern-request-boundaries.drawio",
"four-pattern-request-boundaries.mmd",
"four-pattern-request-boundaries.d2",
"four-pattern-request-boundaries.dot",
"four-pattern-request-boundaries.excalidraw",
"four-pattern-request-boundaries.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "comparison",
"reference_ids": [
"contract-comparison"
],
"diagram_only": true
}
@@ -1,7 +0,0 @@
%% 로그인 후 요청에서 달라지는 네 인증 경계
%% question: AP1부터 AP4까지 브라우저 입력, credential 변환, 보호 자원 입력과 최종 출력은 어떻게 다른가?
flowchart LR
n0["AP1 · SPA direct"]
n1["AP2 · token mediator"]
n2["AP3 · BFF"]
n3["AP4 · edge forward-auth"]
@@ -1,90 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1126" height="300" viewBox="0 0 1126 300" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">로그인 후 요청에서 달라지는 네 인증 경계</title>
<desc id="diagram-description">왼쪽부터 AP1 SPA direct, AP2 token mediator, AP3 BFF, AP4 edge forward-auth를 읽는다. 각 항목은 브라우저가 보내는 입력, 중간 계층의 변환, 보호 자원이 실제로 받는 credential, 브라우저가 받는 출력을 같은 순서로 보여 준다. AP1과 AP2는 브라우저가 Bearer JWT로 API를 직접 호출하고, AP3는 BFF가 Bearer JWT를 조립하며, AP4는 Nginx가 인증 결과를 identity header와 internal token으로 바꾼다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;four-pattern-request-boundaries&quot;,&quot;profile&quot;:&quot;comparison&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;document.md&quot;,&quot;document_sha256&quot;:&quot;df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;marker&quot;,&quot;value&quot;:&quot;four-pattern-request-boundaries&quot;,&quot;line&quot;:114}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
.edge.style-dotted { stroke-dasharray: 2 5; }
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.emphasis-muted { stroke: #9ca3af; }
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
.edge-label-bg { fill: #ffffff; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
.controller-led { fill: #4b5563; }
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
.node-detail { font-size: 11px; fill: #374151; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="1126" height="300" />
<g id="node-ap1">
<rect class="node-shape kind-concept emphasis-primary role-option" data-evidence="90-101,107-109" x="45.0" y="45.0" width="220.0" height="142.0" rx="7" />
<text class="node-label" x="155.0" y="82.0">AP1 · SPA direct</text>
<line class="node-detail-divider" x1="59.0" y1="103.0" x2="251.0" y2="103.0" />
<text class="node-detail" x="61.0" y="120.0">입력 · Bearer access token</text>
<text class="node-detail" x="61.0" y="136.0">변환 · 없음</text>
<text class="node-detail" x="61.0" y="152.0">자원 · 동일 Bearer JWT</text>
<text class="node-detail" x="61.0" y="168.0">출력 · /api/me JSON</text>
</g>
<g id="node-ap2">
<rect class="node-shape kind-concept emphasis-normal role-option" data-evidence="90-101,107-110" x="317.0" y="45.0" width="220.0" height="142.0" rx="7" />
<text class="node-label" x="427.0" y="82.0">AP2 · token mediator</text>
<line class="node-detail-divider" x1="331.0" y1="103.0" x2="523.0" y2="103.0" />
<text class="node-detail" x="333.0" y="120.0">입력 · session 뒤 Bearer</text>
<text class="node-detail" x="333.0" y="136.0">변환 · access JSON 반환</text>
<text class="node-detail" x="333.0" y="152.0">자원 · browser-created JWT</text>
<text class="node-detail" x="333.0" y="168.0">출력 · token JSON + /api/me</text>
</g>
<g id="node-ap3">
<rect class="node-shape kind-concept emphasis-normal role-option" data-evidence="90-101,107-111" x="589.0" y="45.0" width="220.0" height="142.0" rx="7" />
<text class="node-label" x="699.0" y="82.0">AP3 · BFF</text>
<line class="node-detail-divider" x1="603.0" y1="103.0" x2="795.0" y2="103.0" />
<text class="node-detail" x="605.0" y="120.0">입력 · AP3_SESSION</text>
<text class="node-detail" x="605.0" y="136.0">변환 · BFF Bearer 생성</text>
<text class="node-detail" x="605.0" y="152.0">자원 · BFF Bearer JWT</text>
<text class="node-detail" x="605.0" y="168.0">출력 · 중계된 JSON</text>
</g>
<g id="node-ap4">
<rect class="node-shape kind-concept emphasis-normal role-option" data-evidence="90-101,107-112" x="861.0" y="45.0" width="220.0" height="142.0" rx="7" />
<text class="node-label" x="971.0" y="73.0">AP4 · edge</text>
<text class="node-label" x="971.0" y="91.0">forward-auth</text>
<line class="node-detail-divider" x1="875.0" y1="112.0" x2="1067.0" y2="112.0" />
<text class="node-detail" x="877.0" y="129.0">입력 · AP4_SESSION</text>
<text class="node-detail" x="877.0" y="145.0">변환 · auth 결과 투영</text>
<text class="node-detail" x="877.0" y="161.0">자원 · identity + internal</text>
<text class="node-detail" x="877.0" y="177.0">출력 · /edge/me JSON</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.1 KiB

@@ -10,25 +10,25 @@
## Elements and evidence
- **Boundary: 로그인 구간** (lifecycle): Authorization request, callback, code 교환과 로그인 상태 생성이 일어나는 구간. Evidence: L37L39.
- **Boundary: 애플리케이션 요청 구간** (lifecycle): 브라우저 입력부터 credential 변환, 보호 자원 검증과 최종 응답까지의 구간. Evidence: L37L40.
- **Boundary: 로그인 구간** (lifecycle): Authorization request, callback, code 교환과 로그인 상태 생성이 일어나는 구간. Evidence: L39L41.
- **Boundary: 애플리케이션 요청 구간** (lifecycle): 브라우저 입력부터 credential 변환, 보호 자원 검증과 최종 응답까지의 구간. Evidence: L39L42.
- **Keycloak** (service): Authorization request를 받고 authorization code를 OAuth client에 돌려준다. Evidence: L33L33.
- **AP2 mediator** (service): AP2에서 token을 발급받는 주체. Evidence: L35L35.
- **AP3 BFF · 로그인** (service): AP3에서 token을 발급받는 BFF의 로그인 책임. Evidence: L35L35.
- **AP4 oauth2-proxy** (service): AP4에서 OIDC code 교환과 proxy session 검증을 맡는다. Evidence: L35L35.
- **AP2 브라우저** (actor): AP2에서 token 발급 주체와 달리 실제 API를 호출한다. Evidence: L35L35.
- **AP3 BFF · API** (service): AP3에서 token을 발급받은 같은 BFF가 API도 호출한다. Evidence: L35L35.
- **AP4 Nginx** (service): AP4에서 upstream 요청을 연결하고 identity header를 조립한다. Evidence: L35L35.
- **보호 자원** (service): 애플리케이션 요청 구간에서 전달된 credential을 검증하고 최종 응답을 만든다. Evidence: L40L40.
- **AP2 mediator** (service): AP2에서 token을 발급받는 주체. Evidence: L37L37.
- **AP3 BFF · 로그인** (service): AP3에서 token을 발급받는 BFF의 로그인 책임. Evidence: L37L37.
- **AP4 oauth2-proxy** (service): AP4에서 OIDC code 교환과 proxy session 검증을 맡는다. Evidence: L37L37.
- **AP2 브라우저** (actor): AP2에서 token 발급 주체와 달리 실제 API를 호출한다. Evidence: L37L37.
- **AP3 BFF · API** (service): AP3에서 token을 발급받은 같은 BFF가 API도 호출한다. Evidence: L37L37.
- **AP4 Nginx** (service): AP4에서 upstream 요청을 연결하고 identity header를 조립한다. Evidence: L37L37.
- **보호 자원** (service): 애플리케이션 요청 구간에서 전달된 credential을 검증하고 최종 응답을 만든다. Evidence: L42L42.
## Relationships
- **AP2 브라우저 → 보호 자원:** /api/me · Bearer. Evidence: L35L40.
- **AP2 mediator → AP2 브라우저:** owner 분리. Evidence: L35L35.
- **AP3 BFF · API → 보호 자원:** /api/me · Bearer. Evidence: L35L40.
- **AP3 BFF · 로그인 → AP3 BFF · API:** 동일 BFF. Evidence: L35L35.
- **AP4 oauth2-proxy → AP4 Nginx:** auth 결과. Evidence: L35L35.
- **AP4 Nginx → 보호 자원:** /edge/me · identity. Evidence: L35L40.
- **Keycloak → AP2 mediator:** code → token. Evidence: L33L35.
- **Keycloak → AP3 BFF · 로그인:** code → token. Evidence: L33L35.
- **Keycloak → AP4 oauth2-proxy:** OIDC code 교환. Evidence: L33L35.
- **AP2 브라우저 → 보호 자원:** Bearer. Evidence: L37L37, L41L42.
- **AP2 mediator → AP2 브라우저:** owner 분리. Evidence: L37L37.
- **AP3 BFF · API → 보호 자원:** Bearer. Evidence: L37L37, L41L42.
- **AP3 BFF · 로그인 → AP3 BFF · API:** 동일 BFF. Evidence: L37L37.
- **AP4 oauth2-proxy → AP4 Nginx:** auth 결과. Evidence: L37L37.
- **AP4 Nginx → 보호 자원:** identity header. Evidence: L37L37, L41L42.
- **Keycloak → AP2 mediator:** code → token. Evidence: L33L33, L37L37.
- **Keycloak → AP3 BFF · 로그인:** code → token. Evidence: L33L33, L37L37.
- **Keycloak → AP4 oauth2-proxy:** OIDC code 교환. Evidence: L33L33, L37L37.
@@ -35,6 +35,6 @@ g0.n0 -> g0.n3: "OIDC code 교환"
g0.n1 -> g1.n4: "owner 분리"
g0.n2 -> g1.n5: "동일 BFF"
g0.n3 -> g1.n6: "auth 결과"
g1.n4 -> g1.n7: "/api/me · Bearer"
g1.n5 -> g1.n7: "/api/me · Bearer"
g1.n6 -> g1.n7: "/edge/me · identity"
g1.n4 -> g1.n7: "Bearer"
g1.n5 -> g1.n7: "Bearer"
g1.n6 -> g1.n7: "identity header"
@@ -26,7 +26,7 @@ digraph techviz {
n1 -> n4 [label="owner 분리", style=solid];
n2 -> n5 [label="동일 BFF", style=solid];
n3 -> n6 [label="auth 결과", style=solid];
n4 -> n7 [label="/api/me · Bearer", style=solid];
n5 -> n7 [label="/api/me · Bearer", style=solid];
n6 -> n7 [label="/edge/me · identity", style=solid];
n4 -> n7 [label="Bearer", style=solid];
n5 -> n7 [label="Bearer", style=solid];
n6 -> n7 [label="identity header", style=solid];
}
@@ -14,28 +14,28 @@
<mxCell id="n_keycloak" value="Keycloak" tooltip="Authorization request를 받고 authorization code를 OAuth client에 돌려준다. | Evidence: L33-L33" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="70.0" y="242.5" width="150.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="n_ap2-mediator-login" value="AP2 mediator&lt;br/&gt;token 발급 주체" tooltip="AP2에서 token을 발급받는 주체. | Evidence: L35-L35" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxCell id="n_ap2-mediator-login" value="AP2 mediator&lt;br/&gt;token 발급 주체" tooltip="AP2에서 token을 발급받는 주체. | Evidence: L37-L37" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="380.0" y="87.5" width="150.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_ap3-bff-login" value="AP3 BFF · 로그인&lt;br/&gt;token 발급 주체" tooltip="AP3에서 token을 발급받는 BFF의 로그인 책임. | Evidence: L35-L35" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxCell id="n_ap3-bff-login" value="AP3 BFF · 로그인&lt;br/&gt;token 발급 주체" tooltip="AP3에서 token을 발급받는 BFF의 로그인 책임. | Evidence: L37-L37" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="380.0" y="230.5" width="150.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_ap4-oauth2-proxy" value="AP4 oauth2-proxy&lt;br/&gt;OIDC code 교환&lt;br/&gt;AP4_SESSION 검증" tooltip="AP4에서 OIDC code 교환과 proxy session 검증을 맡는다. | Evidence: L35-L35" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxCell id="n_ap4-oauth2-proxy" value="AP4 oauth2-proxy&lt;br/&gt;OIDC code 교환&lt;br/&gt;AP4_SESSION 검증" tooltip="AP4에서 OIDC code 교환과 proxy session 검증을 맡는다. | Evidence: L37-L37" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="380.0" y="373.5" width="150.0" height="88.0" as="geometry"/>
</mxCell>
<mxCell id="n_ap2-browser-caller" value="AP2 브라우저&lt;br/&gt;API 호출 주체" tooltip="AP2에서 token 발급 주체와 달리 실제 API를 호출한다. | Evidence: L35-L35" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;dashed=1;fillColor=#f5f7fa;" vertex="1" parent="1">
<mxCell id="n_ap2-browser-caller" value="AP2 브라우저&lt;br/&gt;API 호출 주체" tooltip="AP2에서 token 발급 주체와 달리 실제 API를 호출한다. | Evidence: L37-L37" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;dashed=1;fillColor=#f5f7fa;" vertex="1" parent="1">
<mxGeometry x="695.0" y="81.0" width="150.0" height="84.0" as="geometry"/>
</mxCell>
<mxCell id="n_ap3-bff-caller" value="AP3 BFF · API&lt;br/&gt;API 호출 주체" tooltip="AP3에서 token을 발급받은 같은 BFF가 API도 호출한다. | Evidence: L35-L35" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxCell id="n_ap3-bff-caller" value="AP3 BFF · API&lt;br/&gt;API 호출 주체" tooltip="AP3에서 token을 발급받은 같은 BFF가 API도 호출한다. | Evidence: L37-L37" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="695.0" y="237.0" width="150.0" height="71.0" as="geometry"/>
</mxCell>
<mxCell id="n_ap4-nginx-caller" value="AP4 Nginx&lt;br/&gt;upstream 요청 연결&lt;br/&gt;identity header 조립" tooltip="AP4에서 upstream 요청을 연결하고 identity header를 조립한다. | Evidence: L35-L35" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxCell id="n_ap4-nginx-caller" value="AP4 Nginx&lt;br/&gt;upstream 요청 연결&lt;br/&gt;identity header 조립" tooltip="AP4에서 upstream 요청을 연결하고 identity header를 조립한다. | Evidence: L37-L37" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;" vertex="1" parent="1">
<mxGeometry x="690.0" y="380.0" width="160.0" height="88.0" as="geometry"/>
</mxCell>
<mxCell id="n_protected-resource" value="보호 자원" tooltip="애플리케이션 요청 구간에서 전달된 credential을 검증하고 최종 응답을 만든다. | Evidence: L40-L40" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="1010.0" y="242.5" width="150.0" height="64.0" as="geometry"/>
<mxCell id="n_protected-resource" value="보호 자원&lt;br/&gt;/api/me&lt;br/&gt;/edge/me" tooltip="애플리케이션 요청 구간에서 전달된 credential을 검증하고 최종 응답을 만든다. | Evidence: L42-L42" style="whiteSpace=wrap;html=1;rounded=1;strokeWidth=2;fontSize=14;fontStyle=1;fillColor=#ffffff;strokeColor=#2d4357;verticalAlign=middle;strokeColor=#2563eb;strokeWidth=2;" vertex="1" parent="1">
<mxGeometry x="1010.0" y="230.5" width="150.0" height="88.0" as="geometry"/>
</mxCell>
<mxCell id="e_ap2-api-request" value="/api/me · Bearer" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_ap2-browser-caller" target="n_protected-resource">
<mxCell id="e_ap2-api-request" value="Bearer" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_ap2-browser-caller" target="n_protected-resource">
<mxGeometry relative="1" as="geometry">
<mxPoint x="951.5" y="189.8" as="offset"/>
</mxGeometry>
@@ -45,7 +45,7 @@
<mxPoint x="612.5" y="95.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_ap3-api-request" value="/api/me · Bearer" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_ap3-bff-caller" target="n_protected-resource">
<mxCell id="e_ap3-api-request" value="Bearer" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_ap3-bff-caller" target="n_protected-resource">
<mxGeometry relative="1" as="geometry">
<mxPoint x="951.5" y="273.5" as="offset"/>
</mxGeometry>
@@ -60,7 +60,7 @@
<mxPoint x="634.0" y="420.8" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_ap4-upstream-request" value="/edge/me · identity" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_ap4-nginx-caller" target="n_protected-resource">
<mxCell id="e_ap4-upstream-request" value="identity header" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_ap4-nginx-caller" target="n_protected-resource">
<mxGeometry relative="1" as="geometry">
<mxPoint x="954.0" y="358.2" as="offset"/>
</mxGeometry>
@@ -204,9 +204,9 @@
{
"id": "edge-label-ap2-api-request",
"type": "text",
"x": 887.5,
"x": 906.5,
"y": 177.75,
"width": 128,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -232,11 +232,11 @@
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "/api/me · Bearer",
"text": "Bearer",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "/api/me · Bearer",
"originalText": "Bearer",
"autoResize": true,
"lineHeight": 1.25
},
@@ -402,9 +402,9 @@
{
"id": "edge-label-ap3-api-request",
"type": "text",
"x": 887.5,
"x": 906.5,
"y": 261.5,
"width": 128,
"width": 90,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -430,11 +430,11 @@
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "/api/me · Bearer",
"text": "Bearer",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "/api/me · Bearer",
"originalText": "Bearer",
"autoResize": true,
"lineHeight": 1.25
},
@@ -699,9 +699,9 @@
{
"id": "edge-label-ap4-upstream-request",
"type": "text",
"x": 878.0,
"x": 894.0,
"y": 346.25,
"width": 152,
"width": 120,
"height": 24,
"angle": 0,
"strokeColor": "#1e1e1e",
@@ -727,11 +727,11 @@
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "/edge/me · identity",
"text": "identity header",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "/edge/me · identity",
"originalText": "identity header",
"autoResize": true,
"lineHeight": 1.25
},
@@ -1519,9 +1519,9 @@
"id": "node-protected-resource",
"type": "rectangle",
"x": 1010.0,
"y": 242.5,
"y": 230.5,
"width": 150.0,
"height": 64.0,
"height": 88.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
@@ -1549,9 +1549,9 @@
"id": "node-label-protected-resource",
"type": "text",
"x": 1020.0,
"y": 252.5,
"y": 240.5,
"width": 130.0,
"height": 44.0,
"height": 68.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
@@ -1576,11 +1576,11 @@
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "보호 자원",
"text": "보호 자원\n/api/me\n/edge/me",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "보호 자원",
"originalText": "보호 자원\n/api/me\n/edge/me",
"autoResize": true,
"lineHeight": 1.25
}
@@ -2,14 +2,14 @@
"harness_version": "0.2.0",
"spec_id": "login-api-phase-split",
"spec_version": "1.1",
"spec_sha256": "1e4e83b518a818e4a5ba6574ce7064b13bed1a1ab8b9bb842229a50e92442ecc",
"spec_sha256": "5895a0f2bfe3d0fe1ced46147651689dc65c3488695300029ffaedf07490db71",
"source_context": {
"document": "document.md",
"document_sha256": "df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371",
"document_sha256": "e2cc061f683294c937f089ad874cbf7a617f7313c07a92c2148fb11287a0da37",
"anchor": {
"kind": "marker",
"value": "login-api-phase-split",
"line": 42
"kind": "heading",
"value": "로그인 흐름과 API 흐름은 같은 선이 아니다",
"line": 31
}
},
"outputs": [
@@ -19,8 +19,8 @@ flowchart LR
n1 -->|"owner 분리"| n4
n2 -->|"동일 BFF"| n5
n3 -->|"auth 결과"| n6
n4 -->|"/api/me · Bearer"| n7
n5 -->|"/api/me · Bearer"| n7
n6 -->|"/edge/me · identity"| n7
n4 -->|"Bearer"| n7
n5 -->|"Bearer"| n7
n6 -->|"identity header"| n7
classDef external stroke-dasharray: 6 4
class n4 external
@@ -2,7 +2,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1235" height="539" viewBox="0 0 1235 539" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">로그인 credential owner와 API caller는 같은 축이 아니다</title>
<desc id="diagram-description">왼쪽 로그인 구간에는 Keycloak과 AP2 mediator, AP3 BFF, AP4 oauth2-proxy가 있다. Keycloak의 authorization code는 각 OAuth client 쪽으로 이동한다. 오른쪽 애플리케이션 요청 구간에는 AP2 브라우저, AP3 BFF, AP4 Nginx와 보호 자원이 있다. AP2는 token을 받는 mediator와 API를 호출하는 브라우저가 갈리고, AP3는 같은 BFF가 token 발급과 API 호출을 소유한다. AP4는 oauth2-proxy가 code 교환과 session 검증을 맡고 Nginx가 upstream 요청 연결과 identity header 조립을 맡는다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;login-api-phase-split&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;document.md&quot;,&quot;document_sha256&quot;:&quot;df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;marker&quot;,&quot;value&quot;:&quot;login-api-phase-split&quot;,&quot;line&quot;:42}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;login-api-phase-split&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;document.md&quot;,&quot;document_sha256&quot;:&quot;e2cc061f683294c937f089ad874cbf7a617f7313c07a92c2148fb11287a0da37&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;heading&quot;,&quot;value&quot;:&quot;로그인 흐름과 API 흐름은 같은 선이 아니다&quot;,&quot;line&quot;:31}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
@@ -56,31 +56,31 @@
<rect class="group-box" x="660.0" y="35.0" width="530.0" height="459.0" rx="8" />
<rect class="group-label-bg" x="674.0" y="25.0" width="106.0" height="22" />
<text class="group-label" x="684.0" y="40.0">애플리케이션 요청 구간</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="845.0,123.0 927.5,123.0 927.5,256.5 1010.0,256.5" data-evidence="35-40" />
<rect class="edge-label-bg" x="888.9" y="175.8" width="125.2" height="22" rx="3" />
<text class="edge-label" x="951.5" y="190.8">/api/me · Bearer</text>
<polyline class="edge kind-handoff style-solid emphasis-normal" points="530.0,123.0 612.5,123.0 612.5,123.0 695.0,123.0" data-evidence="35-35" />
<polyline class="edge kind-request style-solid emphasis-normal" points="845.0,123.0 927.5,123.0 927.5,256.5 1010.0,256.5" data-evidence="37-37,41-42" />
<rect class="edge-label-bg" x="922.4" y="175.8" width="58.2" height="22" rx="3" />
<text class="edge-label" x="951.5" y="190.8">Bearer</text>
<polyline class="edge kind-handoff style-solid emphasis-normal" points="530.0,123.0 612.5,123.0 612.5,123.0 695.0,123.0" data-evidence="37-37" />
<rect class="edge-label-bg" x="576.7" y="81.0" width="71.6" height="22" rx="3" />
<text class="edge-label" x="612.5" y="96.0">owner 분리</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="845.0,272.5 927.5,272.5 927.5,274.5 1010.0,274.5" data-evidence="35-40" />
<rect class="edge-label-bg" x="888.9" y="259.5" width="125.2" height="22" rx="3" />
<text class="edge-label" x="951.5" y="274.5">/api/me · Bearer</text>
<polyline class="edge kind-responsibility style-solid emphasis-normal" points="530.0,266.0 612.5,266.0 612.5,272.5 695.0,272.5" data-evidence="35-35" />
<polyline class="edge kind-request style-solid emphasis-normal" points="845.0,272.5 927.5,272.5 927.5,274.5 1010.0,274.5" data-evidence="37-37,41-42" />
<rect class="edge-label-bg" x="922.4" y="259.5" width="58.2" height="22" rx="3" />
<text class="edge-label" x="951.5" y="274.5">Bearer</text>
<polyline class="edge kind-responsibility style-solid emphasis-normal" points="530.0,266.0 612.5,266.0 612.5,272.5 695.0,272.5" data-evidence="37-37" />
<rect class="edge-label-bg" x="607.4" y="255.2" width="58.2" height="22" rx="3" />
<text class="edge-label" x="636.5" y="270.2">동일 BFF</text>
<polyline class="edge kind-handoff style-solid emphasis-normal" points="530.0,417.5 610.0,417.5 610.0,424.0 690.0,424.0" data-evidence="35-35" />
<polyline class="edge kind-handoff style-solid emphasis-normal" points="530.0,417.5 610.0,417.5 610.0,424.0 690.0,424.0" data-evidence="37-37" />
<rect class="edge-label-bg" x="601.5" y="406.8" width="64.9" height="22" rx="3" />
<text class="edge-label" x="634.0" y="421.8">auth 결과</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="850.0,424.0 930.0,424.0 930.0,292.5 1010.0,292.5" data-evidence="35-40" />
<rect class="edge-label-bg" x="881.4" y="344.2" width="145.3" height="22" rx="3" />
<text class="edge-label" x="954.0" y="359.2">/edge/me · identity</text>
<polyline class="edge kind-data style-solid emphasis-normal" points="220.0,256.5 300.0,256.5 300.0,123.0 380.0,123.0" data-evidence="33-35" />
<polyline class="edge kind-request style-solid emphasis-normal" points="850.0,424.0 930.0,424.0 930.0,292.5 1010.0,292.5" data-evidence="37-37,41-42" />
<rect class="edge-label-bg" x="894.8" y="344.2" width="118.5" height="22" rx="3" />
<text class="edge-label" x="954.0" y="359.2">identity header</text>
<polyline class="edge kind-data style-solid emphasis-normal" points="220.0,256.5 300.0,256.5 300.0,123.0 380.0,123.0" data-evidence="33-33,37-37" />
<rect class="edge-label-bg" x="274.8" y="175.8" width="98.4" height="22" rx="3" />
<text class="edge-label" x="324.0" y="190.8">code → token</text>
<polyline class="edge kind-data style-solid emphasis-normal" points="220.0,274.5 300.0,274.5 300.0,266.0 380.0,266.0" data-evidence="33-35" />
<polyline class="edge kind-data style-solid emphasis-normal" points="220.0,274.5 300.0,274.5 300.0,266.0 380.0,266.0" data-evidence="33-33,37-37" />
<rect class="edge-label-bg" x="274.8" y="256.2" width="98.4" height="22" rx="3" />
<text class="edge-label" x="324.0" y="271.2">code → token</text>
<polyline class="edge kind-data style-solid emphasis-normal" points="220.0,292.5 300.0,292.5 300.0,417.5 380.0,417.5" data-evidence="33-35" />
<polyline class="edge kind-data style-solid emphasis-normal" points="220.0,292.5 300.0,292.5 300.0,417.5 380.0,417.5" data-evidence="33-33,37-37" />
<rect class="edge-label-bg" x="274.8" y="341.0" width="98.4" height="22" rx="3" />
<text class="edge-label" x="324.0" y="356.0">OIDC code 교환</text>
<g id="node-keycloak">
@@ -88,43 +88,46 @@
<text class="node-label" x="145.0" y="272.5">Keycloak</text>
</g>
<g id="node-ap2-mediator-login">
<rect class="node-shape kind-service emphasis-normal role-service" data-evidence="35-35" x="380.0" y="87.5" width="150.0" height="71.0" rx="7" />
<rect class="node-shape kind-service emphasis-normal role-service" data-evidence="37-37" x="380.0" y="87.5" width="150.0" height="71.0" rx="7" />
<text class="node-label" x="455.0" y="114.5">AP2 mediator</text>
<line class="node-detail-divider" x1="394.0" y1="135.5" x2="516.0" y2="135.5" />
<text class="node-detail" x="396.0" y="152.5">token 발급 주체</text>
</g>
<g id="node-ap3-bff-login">
<rect class="node-shape kind-service emphasis-normal role-service" data-evidence="35-35" x="380.0" y="230.5" width="150.0" height="71.0" rx="7" />
<rect class="node-shape kind-service emphasis-normal role-service" data-evidence="37-37" x="380.0" y="230.5" width="150.0" height="71.0" rx="7" />
<text class="node-label" x="455.0" y="257.5">AP3 BFF · 로그인</text>
<line class="node-detail-divider" x1="394.0" y1="278.5" x2="516.0" y2="278.5" />
<text class="node-detail" x="396.0" y="295.5">token 발급 주체</text>
</g>
<g id="node-ap4-oauth2-proxy">
<rect class="node-shape kind-service emphasis-normal role-service" data-evidence="35-35" x="380.0" y="373.5" width="150.0" height="88.0" rx="7" />
<rect class="node-shape kind-service emphasis-normal role-service" data-evidence="37-37" x="380.0" y="373.5" width="150.0" height="88.0" rx="7" />
<text class="node-label" x="455.0" y="400.5">AP4 oauth2-proxy</text>
<line class="node-detail-divider" x1="394.0" y1="421.5" x2="516.0" y2="421.5" />
<text class="node-detail" x="396.0" y="438.5">OIDC code 교환</text>
<text class="node-detail" x="396.0" y="454.5">AP4_SESSION 검증</text>
</g>
<g id="node-ap2-browser-caller">
<g class="actor-symbol emphasis-normal" data-evidence="35-35"><circle cx="770.0" cy="101.0" r="11.0" /><line x1="770.0" y1="117.0" x2="770.0" y2="136.0" /><line x1="752.0" y1="127.0" x2="788.0" y2="127.0" /><line x1="770.0" y1="136.0" x2="755.0" y2="153.0" /><line x1="770.0" y1="136.0" x2="785.0" y2="153.0" /></g>
<g class="actor-symbol emphasis-normal" data-evidence="37-37"><circle cx="770.0" cy="101.0" r="11.0" /><line x1="770.0" y1="117.0" x2="770.0" y2="126.0" /><line x1="752.0" y1="127.0" x2="788.0" y2="127.0" /><line x1="770.0" y1="126.0" x2="755.0" y2="143.0" /><line x1="770.0" y1="126.0" x2="785.0" y2="143.0" /></g>
<text class="node-label" x="770.0" y="158.0">AP2 브라우저</text>
</g>
<g id="node-ap3-bff-caller">
<rect class="node-shape kind-service emphasis-normal role-service" data-evidence="35-35" x="695.0" y="237.0" width="150.0" height="71.0" rx="7" />
<rect class="node-shape kind-service emphasis-normal role-service" data-evidence="37-37" x="695.0" y="237.0" width="150.0" height="71.0" rx="7" />
<text class="node-label" x="770.0" y="264.0">AP3 BFF · API</text>
<line class="node-detail-divider" x1="709.0" y1="285.0" x2="831.0" y2="285.0" />
<text class="node-detail" x="711.0" y="302.0">API 호출 주체</text>
</g>
<g id="node-ap4-nginx-caller">
<rect class="node-shape kind-service emphasis-normal role-service" data-evidence="35-35" x="690.0" y="380.0" width="160.0" height="88.0" rx="7" />
<rect class="node-shape kind-service emphasis-normal role-service" data-evidence="37-37" x="690.0" y="380.0" width="160.0" height="88.0" rx="7" />
<text class="node-label" x="770.0" y="407.0">AP4 Nginx</text>
<line class="node-detail-divider" x1="704.0" y1="428.0" x2="836.0" y2="428.0" />
<text class="node-detail" x="706.0" y="445.0">upstream 요청 연결</text>
<text class="node-detail" x="706.0" y="461.0">identity header 조립</text>
</g>
<g id="node-protected-resource">
<rect class="node-shape kind-service emphasis-primary role-sink" data-evidence="40-40" x="1010.0" y="242.5" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="1085.0" y="272.5">보호 자원</text>
<rect class="node-shape kind-service emphasis-primary role-sink" data-evidence="42-42" x="1010.0" y="230.5" width="150.0" height="88.0" rx="7" />
<text class="node-label" x="1085.0" y="257.5">보호 자원</text>
<line class="node-detail-divider" x1="1024.0" y1="278.5" x2="1146.0" y2="278.5" />
<text class="node-detail" x="1026.0" y="295.5">/api/me</text>
<text class="node-detail" x="1026.0" y="311.5">/edge/me</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

@@ -1,58 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 3.0 KiB

@@ -1,58 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 3.0 KiB

@@ -1,62 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 3.3 KiB

@@ -1,84 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="870" height="542" viewBox="0 0 870 542" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">AP3 session cookie에서 BFF downstream Bearer까지</title>
<desc id="diagram-description">브라우저가 Authorization header 없이 AP3_SESSION cookie로 /bff/api/me를 호출한다. BFF는 현재 Authentication으로 authorized-client manager를 호출해 server-held access token을 얻고 Resource Server의 /api/me에 Bearer header를 붙인다. Resource Server가 JWT를 검증해 사용자 JSON을 반환하면 BFF가 ResponseEntity로 받아 브라우저에 중계한다. 브라우저 session cookie는 downstream으로 전달되지 않는다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;ap3-bff-session-flow&quot;,&quot;profile&quot;:&quot;sequence&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;document.md&quot;,&quot;document_sha256&quot;:&quot;df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;marker&quot;,&quot;value&quot;:&quot;ap3-bff-session-flow&quot;,&quot;line&quot;:908}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
.edge.style-dotted { stroke-dasharray: 2 5; }
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.emphasis-muted { stroke: #9ca3af; }
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
.edge-label-bg { fill: #ffffff; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
.controller-led { fill: #4b5563; }
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
.node-detail { font-size: 11px; fill: #374151; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="870" height="542" />
<rect class="node-shape kind-participant emphasis-normal role-participant" data-evidence="714-722,750-760,796-809" x="45.0" y="35.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="120.0" y="65.0">브라우저</text>
<line class="lifeline" x1="120.0" y1="99.0" x2="120.0" y2="512.0" />
<rect class="node-shape kind-participant emphasis-primary role-participant" data-evidence="750-783,796-809" x="255.0" y="35.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="330.0" y="65.0">Spring BFF</text>
<line class="lifeline" x1="330.0" y1="99.0" x2="330.0" y2="512.0" />
<rect class="node-shape kind-participant emphasis-normal role-participant" data-evidence="700-712,762-768" x="465.0" y="35.0" width="153.0" height="74.0" rx="7" />
<text class="node-label" x="541.5" y="62.0">Authorized-client</text>
<text class="node-label" x="541.5" y="80.0">store</text>
<line class="lifeline" x1="541.5" y1="109.0" x2="541.5" y2="512.0" />
<rect class="node-shape kind-participant emphasis-normal role-participant" data-evidence="776-796" x="675.0" y="35.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="750.0" y="65.0">Resource Server</text>
<line class="lifeline" x1="750.0" y1="99.0" x2="750.0" y2="512.0" />
<polyline class="edge kind-request style-solid emphasis-normal" points="120.0,140.0 330.0,140.0" data-evidence="750-760" />
<rect class="edge-label-bg" x="108.8" y="114.0" width="232.4" height="22" rx="3" />
<text class="edge-label" x="225.0" y="129.0">1. GET /bff/api/me + AP3_SESSION</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="330.0,202.0 541.5,202.0" data-evidence="762-768" />
<rect class="edge-label-bg" x="326.2" y="176.0" width="219.0" height="22" rx="3" />
<text class="edge-label" x="435.8" y="191.0">2. authorize current principal</text>
<polyline class="edge kind-response style-dashed emphasis-normal semantic-dashed" points="541.5,264.0 330.0,264.0" data-evidence="762-768" />
<rect class="edge-label-bg" x="336.3" y="238.0" width="198.9" height="22" rx="3" />
<text class="edge-label" x="435.8" y="253.0">3. server-held access token</text>
<polyline class="edge kind-request style-solid emphasis-primary" points="330.0,326.0 750.0,326.0" data-evidence="770-783" />
<rect class="edge-label-bg" x="410.4" y="300.0" width="259.2" height="22" rx="3" />
<text class="edge-label" x="540.0" y="315.0">4. GET /api/me · Bearer access token</text>
<polyline class="edge kind-response style-dashed emphasis-normal semantic-dashed" points="750.0,388.0 330.0,388.0" data-evidence="785-796" />
<rect class="edge-label-bg" x="393.6" y="362.0" width="292.7" height="22" rx="3" />
<text class="edge-label" x="540.0" y="377.0">5. subject · username · issuer · audience</text>
<polyline class="edge kind-response style-dashed emphasis-normal semantic-dashed" points="330.0,450.0 120.0,450.0" data-evidence="796-809" />
<rect class="edge-label-bg" x="95.4" y="424.0" width="259.2" height="22" rx="3" />
<text class="edge-label" x="225.0" y="439.0">6. BFF ResponseEntity → browser JSON</text>
</svg>

Before

Width:  |  Height:  |  Size: 7.5 KiB

@@ -1,104 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="1411" height="392" viewBox="0 0 1411 392" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">AP3의 masked CSRF 응답과 raw POST credential</title>
<desc id="diagram-description">왼쪽의 BFF CSRF endpoint에서 두 결과가 갈라진다. XSRF-TOKEN cookie에는 raw token이 저장되고 JSON body에는 XOR와 Base64로 masked된 token 및 headerName이 담긴다. 두 결과는 SPA의 POST 조립 단계로 모이지만, JSON에서는 headerName만 사용하고 실제 X-XSRF-TOKEN 값은 document.cookie에서 읽은 raw token이다. POST에는 같은 raw 값을 가진 cookie와 header가 함께 도달하고 Spring CSRF filter가 일치 여부를 확인한다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;ap3-csrf-boundary&quot;,&quot;profile&quot;:&quot;component-flow&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;document.md&quot;,&quot;document_sha256&quot;:&quot;df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;marker&quot;,&quot;value&quot;:&quot;ap3-csrf-boundary&quot;,&quot;line&quot;:858}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
.edge.style-dotted { stroke-dasharray: 2 5; }
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.emphasis-muted { stroke: #9ca3af; }
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
.edge-label-bg { fill: #ffffff; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
.controller-led { fill: #4b5563; }
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
.node-detail { font-size: 11px; fill: #374151; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="1411" height="392" />
<polyline class="edge kind-request style-solid emphasis-primary" points="990.0,203.5 1070.0,203.5 1070.0,203.5 1150.0,203.5" data-evidence="848-873" />
<rect class="edge-label-bg" x="984.0" y="161.5" width="172.1" height="22" rx="3" />
<text class="edge-label" x="1070.0" y="176.5">Cookie raw = Header raw</text>
<polyline class="edge kind-response style-solid emphasis-normal semantic-dashed" points="220.0,194.5 300.0,194.5 300.0,115.0 380.0,115.0" data-evidence="836-844" />
<rect class="edge-label-bg" x="278.1" y="140.8" width="91.7" height="22" rx="3" />
<text class="edge-label" x="324.0" y="155.8">masked JSON</text>
<polyline class="edge kind-response style-solid emphasis-normal semantic-dashed" points="220.0,212.5 309.8,212.5 309.8,294.5 399.5,294.5" data-evidence="827-834" />
<rect class="edge-label-bg" x="271.1" y="239.5" width="125.2" height="22" rx="3" />
<text class="edge-label" x="333.8" y="254.5">Set-Cookie · raw</text>
<polyline class="edge kind-data style-solid emphasis-normal" points="600.0,115.0 680.0,115.0 680.0,194.5 760.0,194.5" data-evidence="846-856" />
<rect class="edge-label-bg" x="644.8" y="140.8" width="118.5" height="22" rx="3" />
<text class="edge-label" x="704.0" y="155.8">headerName only</text>
<polyline class="edge kind-data style-solid emphasis-primary" points="580.5,294.5 670.2,294.5 670.2,212.5 760.0,212.5" data-evidence="846-856" />
<rect class="edge-label-bg" x="614.9" y="239.5" width="158.7" height="22" rx="3" />
<text class="edge-label" x="694.2" y="254.5">document.cookie · raw</text>
<g id="node-csrf-endpoint">
<rect class="node-shape kind-service emphasis-normal role-source" data-evidence="819-844" x="70.0" y="171.5" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="145.0" y="201.5">BFF · /bff/csrf</text>
</g>
<g id="node-masked-json">
<path class="node-shape kind-data emphasis-normal role-store" data-evidence="836-846" d="M 380.0 60.0 H 569.2 L 600.0 90.8 V 170.0 H 380.0 Z M 569.2 60.0 V 90.8 H 600.0" />
<text class="node-label" x="490.0" y="89.0">JSON body · masked</text>
<line class="node-detail-divider" x1="394.0" y1="110.0" x2="586.0" y2="110.0" />
<text class="node-detail" x="396.0" y="127.0">token = XOR/Base64</text>
<text class="node-detail" x="396.0" y="143.0">headerName metadata</text>
<text class="node-detail" x="396.0" y="159.0">POST token 값으로 미사용</text>
</g>
<g id="node-raw-cookie">
<rect class="node-shape kind-credential emphasis-primary role-store" data-evidence="827-834,844-856" x="399.5" y="242.0" width="181.0" height="105.0" rx="7" />
<text class="node-label" x="490.0" y="269.0">Browser cookie · raw</text>
<line class="node-detail-divider" x1="413.5" y1="290.0" x2="566.5" y2="290.0" />
<text class="node-detail" x="415.5" y="307.0">XSRF-TOKEN</text>
<text class="node-detail" x="415.5" y="323.0">JavaScript-readable</text>
<text class="node-detail" x="415.5" y="339.0">실제 header data source</text>
</g>
<g id="node-post-assembler">
<rect class="node-shape kind-component emphasis-normal role-service" data-evidence="846-856,862-870" x="760.0" y="151.0" width="230.0" height="105.0" rx="7" />
<text class="node-label" x="875.0" y="178.0">SPA POST 조립</text>
<line class="node-detail-divider" x1="774.0" y1="199.0" x2="976.0" y2="199.0" />
<text class="node-detail" x="776.0" y="216.0">Cookie 자동 첨부</text>
<text class="node-detail" x="776.0" y="232.0">document.cookie raw → header</text>
<text class="node-detail" x="776.0" y="248.0">JSON headerName만 사용</text>
</g>
<g id="node-csrf-filter">
<rect class="node-shape kind-security emphasis-normal role-sink" data-evidence="854-854,873-873,897-904" x="1150.0" y="151.0" width="216.0" height="105.0" rx="7" />
<text class="node-label" x="1258.0" y="178.0">Spring CSRF filter</text>
<line class="node-detail-divider" x1="1164.0" y1="199.0" x2="1352.0" y2="199.0" />
<text class="node-detail" x="1166.0" y="216.0">raw cookie = raw header 비교</text>
<text class="node-detail" x="1166.0" y="232.0">일치 → controller</text>
<text class="node-detail" x="1166.0" y="248.0">부재·불일치 → 403</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 8.5 KiB

@@ -1,48 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2.3 KiB

@@ -1,83 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="870" height="542" viewBox="0 0 870 542" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">AP4 proxy session에서 trusted identity JSON까지</title>
<desc id="diagram-description">브라우저가 AP4_SESSION cookie로 Nginx의 /api/edge를 호출한다. Nginx는 oauth2-proxy의 internal auth endpoint에 subrequest를 보내고 인증된 user와 email 결과를 받는다. 이어서 client가 보낸 동명 header를 사용하지 않고 oauth2-proxy 결과와 Nginx 환경의 internal token으로 /edge/me 요청을 새로 조립한다. Spring controller가 user header와 internal token을 함께 확인해 identity JSON을 만들고 Nginx가 브라우저에 전달한다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;ap4-edge-forward-auth-flow&quot;,&quot;profile&quot;:&quot;sequence&quot;},&quot;source_context&quot;:{&quot;document&quot;:&quot;document.md&quot;,&quot;document_sha256&quot;:&quot;df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;marker&quot;,&quot;value&quot;:&quot;ap4-edge-forward-auth-flow&quot;,&quot;line&quot;:1108}},&quot;evidence_policy&quot;:&quot;Each factual element cites source lines or is marked assumption.&quot;,&quot;diagram_only&quot;:true}</metadata>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
<style>
:root { color-scheme: light; }
text { font-family: Inter, Pretendard, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #111827; }
.canvas { fill: #ffffff; }
.group-box { fill: #ffffff; stroke: #9ca3af; stroke-width: 1.4; stroke-dasharray: 7 5; }
.group-label-bg { fill: #ffffff; }
.group-label { font-size: 13px; font-weight: 650; fill: #374151; }
.edge { fill: none; stroke: #374151; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; marker-end: url(#arrow); }
.edge.style-dashed, .edge.semantic-dashed, .edge.assumption { stroke-dasharray: 7 5; }
.edge.style-dotted { stroke-dasharray: 2 5; }
.edge.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.edge.emphasis-muted { stroke: #9ca3af; }
.edge.emphasis-warning, .edge.kind-failure, .edge.kind-error { stroke: #dc2626; stroke-width: 2.2; }
.edge-label-bg { fill: #ffffff; }
.edge-label { font-size: 12px; font-weight: 560; text-anchor: middle; }
.node-shape { fill: #ffffff; stroke: #4b5563; stroke-width: 1.7; }
.node-shape.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-shape.emphasis-muted { stroke: #9ca3af; fill: #f9fafb; }
.node-shape.emphasis-warning { stroke: #d97706; stroke-width: 2; fill: #fffdf5; }
.node-shape.kind-database, .node-shape.kind-datastore, .node-shape.kind-storage { fill: #f8fafc; }
.node-shape.kind-queue, .node-shape.kind-event, .node-shape.kind-topic { fill: #fafafa; }
.node-shape.assumption { stroke-dasharray: 4 4; }
.storage-bottom, .controller-divider { fill: none; stroke: #4b5563; stroke-width: 1.4; }
.controller-led { fill: #4b5563; }
.actor-symbol { fill: none; stroke: #4b5563; stroke-width: 1.8; stroke-linecap: round; }
.actor-symbol.emphasis-primary { stroke: #2563eb; stroke-width: 2.2; }
.node-label { font-size: 14px; font-weight: 650; text-anchor: middle; }
.node-role { font-size: 10px; letter-spacing: 0.04em; text-anchor: middle; fill: #6b7280; }
.node-detail-divider { stroke: #d1d5db; stroke-width: 1; }
.node-detail { font-size: 11px; fill: #374151; }
.assumption-badge { font-size: 9px; font-weight: 700; fill: #92400e; }
.failure-mark { stroke: #dc2626; stroke-width: 4; stroke-linecap: round; }
.lifeline { stroke: #9ca3af; stroke-width: 1.2; stroke-dasharray: 5 5; }
.timeline-axis { stroke: #374151; stroke-width: 1.8; marker-end: url(#arrow); }
.timeline-stem { stroke: #6b7280; stroke-width: 1.3; }
.timeline-marker { fill: #ffffff; stroke: #374151; stroke-width: 1.7; }
.timeline-marker.primary { fill: #2563eb; stroke: #2563eb; }
.timeline-marker.warning { fill: #dc2626; stroke: #dc2626; }
.timeline-label { font-size: 13px; font-weight: 650; text-anchor: middle; }
.timeline-detail { font-size: 11px; fill: #4b5563; text-anchor: middle; }
</style>
</defs>
<rect class="canvas" width="870" height="542" />
<rect class="node-shape kind-participant emphasis-normal role-participant" data-evidence="992-1007" x="45.0" y="35.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="120.0" y="65.0">브라우저</text>
<line class="lifeline" x1="120.0" y1="99.0" x2="120.0" y2="512.0" />
<rect class="node-shape kind-participant emphasis-primary role-participant" data-evidence="914-939,1009-1028" x="255.0" y="35.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="330.0" y="65.0">Nginx edge</text>
<line class="lifeline" x1="330.0" y1="99.0" x2="330.0" y2="512.0" />
<rect class="node-shape kind-participant emphasis-normal role-participant" data-evidence="980-990,1009-1015" x="465.0" y="35.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="540.0" y="65.0">oauth2-proxy</text>
<line class="lifeline" x1="540.0" y1="99.0" x2="540.0" y2="512.0" />
<rect class="node-shape kind-participant emphasis-normal role-participant" data-evidence="1017-1026,1030-1063" x="675.0" y="35.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="750.0" y="65.0">Spring upstream</text>
<line class="lifeline" x1="750.0" y1="99.0" x2="750.0" y2="512.0" />
<polyline class="edge kind-request style-solid emphasis-normal" points="120.0,140.0 330.0,140.0" data-evidence="992-999" />
<rect class="edge-label-bg" x="115.5" y="114.0" width="219.0" height="22" rx="3" />
<text class="edge-label" x="225.0" y="129.0">1. GET /api/edge + AP4_SESSION</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="330.0,202.0 540.0,202.0" data-evidence="922-939,1009-1009" />
<rect class="edge-label-bg" x="308.8" y="176.0" width="252.5" height="22" rx="3" />
<text class="edge-label" x="435.0" y="191.0">2. internal /oauth2/auth subrequest</text>
<polyline class="edge kind-response style-dashed emphasis-normal semantic-dashed" points="540.0,264.0 330.0,264.0" data-evidence="1009-1015" />
<rect class="edge-label-bg" x="328.9" y="238.0" width="212.3" height="22" rx="3" />
<text class="edge-label" x="435.0" y="253.0">3. authenticated user + email</text>
<polyline class="edge kind-request style-solid emphasis-primary" points="330.0,326.0 750.0,326.0" data-evidence="1017-1026" />
<rect class="edge-label-bg" x="380.0" y="300.0" width="320.0" height="22" rx="3" />
<text class="edge-label" x="540.0" y="315.0">4. GET /edge/me · trusted headers + internal token</text>
<polyline class="edge kind-response style-dashed emphasis-normal semantic-dashed" points="750.0,388.0 330.0,388.0" data-evidence="1030-1048" />
<rect class="edge-label-bg" x="450.6" y="362.0" width="178.8" height="22" rx="3" />
<text class="edge-label" x="540.0" y="377.0">5. trusted identity JSON</text>
<polyline class="edge kind-response style-dashed emphasis-normal semantic-dashed" points="330.0,450.0 120.0,450.0" data-evidence="1039-1048,1065-1076" />
<rect class="edge-label-bg" x="75.3" y="424.0" width="299.4" height="22" rx="3" />
<text class="edge-label" x="225.0" y="439.0">6. pattern + user + email + identityHeader</text>
</svg>

Before

Width:  |  Height:  |  Size: 7.5 KiB

@@ -1,66 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 3.4 KiB