chore: readme 수정

This commit is contained in:
DongHyeonka
2026-07-29 18:03:21 +09:00
parent 8daa568746
commit 741c79b69f
180 changed files with 62890 additions and 13 deletions
@@ -0,0 +1,24 @@
# AP1 callback code에서 브라우저 Bearer 요청까지
## Alternative text
브라우저 SPA, Keycloak, Resource Server 사이에서 authorization request, callback, token 교환, Bearer API 호출과 JSON 응답이 이어지는 순서도.
## Long description
브라우저 SPA가 S256 code challenge가 포함된 authorization request를 Keycloak에 보낸다. Keycloak이 code와 state를 callback으로 돌려주면 SPA는 원래 verifier를 포함해 token endpoint에 code를 제출하고 access, refresh, ID token을 받는다. 이어서 SPA가 access token을 Authorization Bearer header에 넣어 Resource Server의 /api/me를 직접 호출하고 사용자 JSON을 받는다.
## Elements and evidence
- **브라우저 SPA** (participant): PKCE transaction과 token set을 처리하고 access token으로 API 요청을 만드는 public client. Evidence: L201L236, L240L280, L303L322.
- **Keycloak** (participant): Authorization code를 발급하고 code와 verifier를 token set으로 교환하는 authorization server. Evidence: L223L263.
- **Resource Server** (participant): Bearer JWT의 signature, issuer, timestamp와 audience를 검증하고 사용자 JSON을 만드는 Spring API. Evidence: L311L353.
## Relationships
- **브라우저 SPA → Keycloak:** authorization request · S256 challenge. Evidence: L223L236.
- **Keycloak → 브라우저 SPA:** callback · code + state. Evidence: L240L250.
- **브라우저 SPA → Keycloak:** token request · code + verifier. Evidence: L250L261.
- **Keycloak → 브라우저 SPA:** access + refresh + ID token. Evidence: L263L280.
- **브라우저 SPA → Resource Server:** GET /api/me · Bearer access token. Evidence: L303L322.
- **Resource Server → 브라우저 SPA:** subject · username · issuer · audience. Evidence: L342L376.
@@ -0,0 +1,18 @@
# AP1 callback code에서 브라우저 Bearer 요청까지
# Question: AP1에서 authorization code는 어떤 순서로 브라우저의 Bearer API 요청과 사용자 JSON이 되는가?
direction: right
n0: "브라우저 SPA" {
shape: rectangle
}
n1: "Keycloak" {
shape: rectangle
}
n2: "Resource Server" {
shape: rectangle
}
n0 -> n1: "authorization request · S256 challenge"
n1 -> n0: "callback · code + state"
n0 -> n1: "token request · code + verifier"
n1 -> n0: "access + refresh + ID token"
n0 -> n2: "GET /api/me · Bearer access token"
n2 -> n0: "subject · username · issuer · audience"
@@ -0,0 +1,14 @@
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="브라우저 SPA", shape=box, style="rounded,filled"];
n1 [label="Keycloak", shape=box, style="rounded,filled"];
n2 [label="Resource Server", shape=box, style="rounded,filled"];
n0 -> n1 [label="authorization request · S256 challenge", style=solid];
n1 -> n0 [label="callback · code + state", style=solid];
n0 -> n1 [label="token request · code + verifier", style=solid];
n1 -> n0 [label="access + refresh + ID token", style=solid];
n0 -> n2 [label="GET /api/me · Bearer access token", style=solid];
n2 -> n0 [label="subject · username · issuer · audience", style=solid];
}
@@ -0,0 +1,50 @@
<?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="ap1-browser-bearer-flow" name="AP1 callback code에서 브라우저 Bearer 요청까지">
<mxGraphModel dx="680" dy="542" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_browser" value="브라우저 SPA" tooltip="PKCE transaction과 token set을 처리하고 access token으로 API 요청을 만드는 public client. | Evidence: L201-L236, L240-L280, L303-L322" 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="35.0" width="150.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="n_keycloak" value="Keycloak" tooltip="Authorization code를 발급하고 code와 verifier를 token set으로 교환하는 authorization server. | Evidence: L223-L263" 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="255.0" y="35.0" width="150.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="n_resource-server" value="Resource Server" tooltip="Bearer JWT의 signature, issuer, timestamp와 audience를 검증하고 사용자 JSON을 만드는 Spring API. | Evidence: L311-L353" 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="465.0" y="35.0" width="150.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="e_authorize" value="authorization request · S256 challenge" 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_keycloak">
<mxGeometry relative="1" as="geometry">
<mxPoint x="225.0" y="128.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_callback" value="callback · code + state" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_keycloak" target="n_browser">
<mxGeometry relative="1" as="geometry">
<mxPoint x="225.0" y="190.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_token-request" value="token request · code + verifier" 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_keycloak">
<mxGeometry relative="1" as="geometry">
<mxPoint x="225.0" y="252.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_token-response" value="access + refresh + ID token" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_keycloak" target="n_browser">
<mxGeometry relative="1" as="geometry">
<mxPoint x="225.0" y="314.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_api-request" value="GET /api/me · Bearer access token" 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_resource-server">
<mxGeometry relative="1" as="geometry">
<mxPoint x="330.0" y="376.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_api-response" value="subject · username · issuer · audience" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_resource-server" target="n_browser">
<mxGeometry relative="1" as="geometry">
<mxPoint x="330.0" y="438.0" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,766 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "edge-authorize",
"type": "arrow",
"x": 120.0,
"y": 140.0,
"width": 210.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 153262066,
"version": 1,
"versionNonce": 1109865188,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
210.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-browser",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-keycloak",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-authorize",
"type": "text",
"x": 73.0,
"y": 116.0,
"width": 304,
"height": 24,
"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": 1306432149,
"version": 1,
"versionNonce": 1642113840,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "authorization request · S256 challenge",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "authorization request · S256 challenge",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-callback",
"type": "arrow",
"x": 120.0,
"y": 202.0,
"width": 210.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1802650704,
"version": 1,
"versionNonce": 1991045792,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
210.0,
0.0
],
[
0.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-keycloak",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-browser",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-callback",
"type": "text",
"x": 133.0,
"y": 178.0,
"width": 184,
"height": 24,
"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": 1929639337,
"version": 1,
"versionNonce": 1816804132,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "callback · code + state",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "callback · code + state",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-token-request",
"type": "arrow",
"x": 120.0,
"y": 264.0,
"width": 210.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 514094626,
"version": 1,
"versionNonce": 942871324,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
210.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-browser",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-keycloak",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-token-request",
"type": "text",
"x": 101.0,
"y": 240.0,
"width": 248,
"height": 24,
"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": 449177001,
"version": 1,
"versionNonce": 1284264247,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "token request · code + verifier",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "token request · code + verifier",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-token-response",
"type": "arrow",
"x": 120.0,
"y": 326.0,
"width": 210.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 924685759,
"version": 1,
"versionNonce": 1455183840,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
210.0,
0.0
],
[
0.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-keycloak",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-browser",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-token-response",
"type": "text",
"x": 117.0,
"y": 302.0,
"width": 216,
"height": 24,
"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": 686621634,
"version": 1,
"versionNonce": 108833043,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "access + refresh + ID token",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "access + refresh + ID token",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-api-request",
"type": "arrow",
"x": 120.0,
"y": 388.0,
"width": 420.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 458447436,
"version": 1,
"versionNonce": 226442684,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
420.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-browser",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-resource-server",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-api-request",
"type": "text",
"x": 198.0,
"y": 364.0,
"width": 264,
"height": 24,
"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": 935709184,
"version": 1,
"versionNonce": 1738417618,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "GET /api/me · Bearer access token",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "GET /api/me · Bearer access token",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-api-response",
"type": "arrow",
"x": 120.0,
"y": 450.0,
"width": 420.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 369211918,
"version": 1,
"versionNonce": 1850377031,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
420.0,
0.0
],
[
0.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-resource-server",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-browser",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-api-response",
"type": "text",
"x": 178.0,
"y": 426.0,
"width": 304,
"height": 24,
"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": 805232354,
"version": 1,
"versionNonce": 214385421,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "subject · username · issuer · audience",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "subject · username · issuer · audience",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-browser",
"type": "rectangle",
"x": 45.0,
"y": 35.0,
"width": 150.0,
"height": 64.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": 1549664096,
"version": 1,
"versionNonce": 397227867,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-browser",
"type": "text",
"x": 55.0,
"y": 45.0,
"width": 130.0,
"height": 44.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": 225442297,
"version": 1,
"versionNonce": 1441680053,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "브라우저 SPA",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "브라우저 SPA",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-keycloak",
"type": "rectangle",
"x": 255.0,
"y": 35.0,
"width": 150.0,
"height": 64.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": 819278378,
"version": 1,
"versionNonce": 1725278844,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-keycloak",
"type": "text",
"x": 265.0,
"y": 45.0,
"width": 130.0,
"height": 44.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": 1265230567,
"version": 1,
"versionNonce": 1115669911,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Keycloak",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Keycloak",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-resource-server",
"type": "rectangle",
"x": 465.0,
"y": 35.0,
"width": 150.0,
"height": 64.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": 972285001,
"version": 1,
"versionNonce": 1174270881,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-resource-server",
"type": "text",
"x": 475.0,
"y": 45.0,
"width": 130.0,
"height": 44.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": 1517556067,
"version": 1,
"versionNonce": 178679525,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Resource Server",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Resource Server",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -0,0 +1,32 @@
{
"harness_version": "0.2.0",
"spec_id": "ap1-browser-bearer-flow",
"spec_version": "1.1",
"spec_sha256": "b78a7bf6ff5d1085c2063a500077d35ab4decf701d59c3d73dc05ca01512e936",
"source_context": {
"document": "document.md",
"document_sha256": "df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371",
"anchor": {
"kind": "marker",
"value": "ap1-browser-bearer-flow",
"line": 395
}
},
"outputs": [
"ap1-browser-bearer-flow.svg",
"ap1-browser-bearer-flow.drawio",
"ap1-browser-bearer-flow.mmd",
"ap1-browser-bearer-flow.d2",
"ap1-browser-bearer-flow.dot",
"ap1-browser-bearer-flow.excalidraw",
"ap1-browser-bearer-flow.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "sequence",
"reference_ids": [
"payment-approval-sequence"
],
"diagram_only": true
}
@@ -0,0 +1,12 @@
%% AP1 callback code에서 브라우저 Bearer 요청까지
%% question: AP1에서 authorization code는 어떤 순서로 브라우저의 Bearer API 요청과 사용자 JSON이 되는가?
sequenceDiagram
participant n0 as 브라우저 SPA
participant n1 as Keycloak
participant n2 as Resource Server
n0->>n1: authorization request · S256 challenge
n1->>n0: callback · code + state
n0->>n1: token request · code + verifier
n1->>n0: access + refresh + ID token
n0->>n2: GET /api/me · Bearer access token
n2->>n0: subject · username · issuer · audience
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="680" height="542" viewBox="0 0 680 542" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">AP1 callback code에서 브라우저 Bearer 요청까지</title>
<desc id="diagram-description">브라우저 SPA가 S256 code challenge가 포함된 authorization request를 Keycloak에 보낸다. Keycloak이 code와 state를 callback으로 돌려주면 SPA는 원래 verifier를 포함해 token endpoint에 code를 제출하고 access, refresh, ID token을 받는다. 이어서 SPA가 access token을 Authorization Bearer header에 넣어 Resource Server의 /api/me를 직접 호출하고 사용자 JSON을 받는다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;ap1-browser-bearer-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;ap1-browser-bearer-flow&quot;,&quot;line&quot;:395}},&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="680" height="542" />
<rect class="node-shape kind-participant emphasis-primary role-participant" data-evidence="201-236,240-280,303-322" x="45.0" y="35.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="120.0" y="65.0">브라우저 SPA</text>
<line class="lifeline" x1="120.0" y1="99.0" x2="120.0" y2="512.0" />
<rect class="node-shape kind-participant emphasis-normal role-participant" data-evidence="223-263" x="255.0" y="35.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="330.0" y="65.0">Keycloak</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="311-353" x="465.0" y="35.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="540.0" y="65.0">Resource Server</text>
<line class="lifeline" x1="540.0" y1="99.0" x2="540.0" y2="512.0" />
<polyline class="edge kind-request style-solid emphasis-normal" points="120.0,140.0 330.0,140.0" data-evidence="223-236" />
<rect class="edge-label-bg" x="78.7" y="114.0" width="292.7" height="22" rx="3" />
<text class="edge-label" x="225.0" y="129.0">1. authorization request · S256 challenge</text>
<polyline class="edge kind-response style-dashed emphasis-normal semantic-dashed" points="330.0,202.0 120.0,202.0" data-evidence="240-250" />
<rect class="edge-label-bg" x="128.9" y="176.0" width="192.2" height="22" rx="3" />
<text class="edge-label" x="225.0" y="191.0">2. callback · code + state</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="120.0,264.0 330.0,264.0" data-evidence="250-261" />
<rect class="edge-label-bg" x="102.1" y="238.0" width="245.8" height="22" rx="3" />
<text class="edge-label" x="225.0" y="253.0">3. token request · code + verifier</text>
<polyline class="edge kind-response style-dashed emphasis-normal semantic-dashed" points="330.0,326.0 120.0,326.0" data-evidence="263-280" />
<rect class="edge-label-bg" x="115.5" y="300.0" width="219.0" height="22" rx="3" />
<text class="edge-label" x="225.0" y="315.0">4. access + refresh + ID token</text>
<polyline class="edge kind-request style-solid emphasis-primary" points="120.0,388.0 540.0,388.0" data-evidence="303-322" />
<rect class="edge-label-bg" x="200.4" y="362.0" width="259.2" height="22" rx="3" />
<text class="edge-label" x="330.0" y="377.0">5. GET /api/me · Bearer access token</text>
<polyline class="edge kind-response style-dashed emphasis-normal semantic-dashed" points="540.0,450.0 120.0,450.0" data-evidence="342-376" />
<rect class="edge-label-bg" x="183.7" y="424.0" width="292.7" height="22" rx="3" />
<text class="edge-label" x="330.0" y="439.0">6. subject · username · issuer · audience</text>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

@@ -0,0 +1,23 @@
# AP1은 OAuth client와 token custody를 브라우저에 둔다
## Alternative text
SPA, Keycloak, 브라우저 JavaScript memory, Resource Server가 왼쪽에서 오른쪽으로 연결된 AP1 직접 인증 아키텍처.
## Long description
왼쪽의 public SPA가 Keycloak과 Authorization Code 및 PKCE S256 계약을 수행한다. Keycloak token 응답의 access, refresh, ID token은 브라우저 JavaScript memory에 놓이며, 그중 access JWT가 오른쪽 Resource Server의 검증 입력이 된다. Resource Server는 issuer, 시간 제약과 keycloak-pattern-api audience를 검증한다.
## Elements and evidence
- **Public SPA** (service): Authorization Code와 PKCE를 직접 다루고 access JWT로 Resource Server를 호출하는 public OAuth client. Evidence: L144L148.
- **Browser JS memory** (database): Access, refresh, ID token을 실행 중 보관하며 reload 뒤에는 복구하지 않는 browser-side custody. Evidence: L148L150.
- **Keycloak** (service): SPA의 code 교환 상대이며 API가 검증하는 JWT 서명의 출처. Evidence: L144L146.
- **Resource Server** (service): Access JWT의 issuer, 시간 제약과 audience를 검증하는 API. Evidence: L144L150.
## Relationships
- **Keycloak → Public SPA:** access · refresh · ID token. Evidence: L144L148.
- **Public SPA → Keycloak:** Authorization Code + PKCE S256. Evidence: L144L146.
- **Public SPA → Browser JS memory:** token set 보관. Evidence: L148L150.
- **Public SPA → Resource Server:** memory-held access JWT · Bearer. Evidence: L144L150.
@@ -0,0 +1,19 @@
# AP1은 OAuth client와 token custody를 브라우저에 둔다
# Question: AP1에서 code 교환, token 보관, Resource Server JWT 입력은 어느 구성 요소에 놓이는가?
direction: right
n0: "Public SPA" {
shape: rectangle
}
n1: "Browser JS memory" {
shape: sql_table
}
n2: "Keycloak" {
shape: rectangle
}
n3: "Resource Server" {
shape: rectangle
}
n0 -> n2: "Authorization Code + PKCE S256"
n2 -> n0: "access · refresh · ID token"
n0 -> n1: "token set 보관"
n0 -> n3: "memory-held access JWT · Bearer"
@@ -0,0 +1,13 @@
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="Public SPA", shape=box, style="rounded,filled"];
n1 [label="Browser JS memory", shape=cylinder, style="rounded,filled"];
n2 [label="Keycloak", shape=box, style="rounded,filled"];
n3 [label="Resource Server", shape=box, style="rounded,filled"];
n0 -> n2 [label="Authorization Code + PKCE S256", style=solid];
n2 -> n0 [label="access · refresh · ID token", style=solid];
n0 -> n1 [label="token set 보관", style=solid];
n0 -> n3 [label="memory-held access JWT · Bearer", style=solid];
}
@@ -0,0 +1,43 @@
<?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="ap1-direct-architecture" name="AP1은 OAuth client와 token custody를 브라우저에 둔다">
<mxGraphModel dx="680" dy="535" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_browser-spa" value="Public SPA&lt;br/&gt;spa-public&lt;br/&gt;PKCE S256" tooltip="Authorization Code와 PKCE를 직접 다루고 access JWT로 Resource Server를 호출하는 public OAuth client. | Evidence: L144-L148" 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="70.0" y="208.0" width="150.0" height="88.0" as="geometry"/>
</mxCell>
<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">
<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">
<mxGeometry x="423.5" y="380.0" width="150.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="e_keycloak-to-spa" value="access · refresh · ID token" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_keycloak" target="n_browser-spa">
<mxGeometry relative="1" as="geometry">
<mxPoint x="247.8" y="462.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_spa-to-keycloak" value="Authorization Code + PKCE S256" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_browser-spa" target="n_keycloak">
<mxGeometry relative="1" as="geometry">
<mxPoint x="307.8" y="340.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_spa-to-memory" value="token set 보관" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_browser-spa" target="n_browser-token-memory">
<mxGeometry relative="1" as="geometry">
<mxPoint x="327.5" y="253.5" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_spa-to-resource-server" value="memory-held access JWT · Bearer" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_browser-spa" target="n_api-resource-server">
<mxGeometry relative="1" as="geometry">
<mxPoint x="324.0" y="164.5" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,685 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "edge-keycloak-to-spa",
"type": "arrow",
"x": 145.0,
"y": 296.0,
"width": 353.5,
"height": 194.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1472844691,
"version": 1,
"versionNonce": 1445012220,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
353.5,
148.0
],
[
353.5,
194.0
],
[
0.0,
194.0
],
[
0.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-keycloak",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-browser-spa",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-keycloak-to-spa",
"type": "text",
"x": 139.75,
"y": 450.0,
"width": 216,
"height": 24,
"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": 1729196669,
"version": 1,
"versionNonce": 152659534,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "access · refresh · ID token",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "access · refresh · ID token",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-spa-to-keycloak",
"type": "arrow",
"x": 220.0,
"y": 279.0,
"width": 203.5,
"height": 142.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 756098302,
"version": 1,
"versionNonce": 1103524562,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
111.75,
0.0
],
[
111.75,
142.0
],
[
203.5,
142.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-browser-spa",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-keycloak",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-spa-to-keycloak",
"type": "text",
"x": 187.75,
"y": 328.0,
"width": 240,
"height": 24,
"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": 1982957731,
"version": 1,
"versionNonce": 1531007620,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "Authorization Code + PKCE S256",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Authorization Code + PKCE S256",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-spa-to-memory",
"type": "arrow",
"x": 220.0,
"y": 243.0,
"width": 167.0,
"height": 21.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1073681399,
"version": 1,
"versionNonce": 1624154339,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
83.5,
0.0
],
[
83.5,
21.0
],
[
167.0,
21.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-browser-spa",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-browser-token-memory",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-spa-to-memory",
"type": "text",
"x": 279.5,
"y": 241.5,
"width": 96,
"height": 24,
"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": 1169729373,
"version": 1,
"versionNonce": 142618791,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "token set 보관",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "token set 보관",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-spa-to-resource-server",
"type": "arrow",
"x": 220.0,
"y": 104.0,
"width": 160.0,
"height": 121.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 307614412,
"version": 1,
"versionNonce": 900388795,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
121.0
],
[
80.0,
121.0
],
[
80.0,
0.0
],
[
160.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-browser-spa",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-api-resource-server",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-spa-to-resource-server",
"type": "text",
"x": 200.0,
"y": 152.5,
"width": 248,
"height": 24,
"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": 293757913,
"version": 1,
"versionNonce": 69180808,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "memory-held access JWT · Bearer",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "memory-held access JWT · Bearer",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-browser-spa",
"type": "rectangle",
"x": 70.0,
"y": 208.0,
"width": 150.0,
"height": 88.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": 842662139,
"version": 1,
"versionNonce": 836393305,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-browser-spa",
"type": "text",
"x": 80.0,
"y": 218.0,
"width": 130.0,
"height": 68.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": 680410563,
"version": 1,
"versionNonce": 168315661,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Public SPA\nspa-public\nPKCE S256",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Public SPA\nspa-public\nPKCE S256",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-api-resource-server",
"type": "rectangle",
"x": 380.0,
"y": 60.0,
"width": 237.0,
"height": 88.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": 1241561907,
"version": 1,
"versionNonce": 950374087,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-api-resource-server",
"type": "text",
"x": 390.0,
"y": 70.0,
"width": 217.0,
"height": 68.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": 138484242,
"version": 1,
"versionNonce": 528224855,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Resource Server\nissuer · time\nkeycloak-pattern-api audience",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Resource Server\nissuer · time\nkeycloak-pattern-api audience",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-browser-token-memory",
"type": "rectangle",
"x": 387.0,
"y": 220.0,
"width": 223.0,
"height": 88.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#e7f5ff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 59858893,
"version": 1,
"versionNonce": 1575317861,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-browser-token-memory",
"type": "text",
"x": 397.0,
"y": 230.0,
"width": 203.0,
"height": 68.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": 455722088,
"version": 1,
"versionNonce": 1442910370,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Browser JS memory\naccess · refresh · ID token\npersistent Web Storage 없음",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Browser JS memory\naccess · refresh · ID token\npersistent Web Storage 없음",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-keycloak",
"type": "rectangle",
"x": 423.5,
"y": 380.0,
"width": 150.0,
"height": 64.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": 819278378,
"version": 1,
"versionNonce": 1725278844,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-keycloak",
"type": "text",
"x": 433.5,
"y": 390.0,
"width": 130.0,
"height": 44.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": 1265230567,
"version": 1,
"versionNonce": 1115669911,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Keycloak",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Keycloak",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -0,0 +1,32 @@
{
"harness_version": "0.2.0",
"spec_id": "ap1-direct-architecture",
"spec_version": "1.1",
"spec_sha256": "71f2cf1931f3be6908cf13db36577eb61cba8d4cdc863bd6057a8c9093fd0bcb",
"source_context": {
"document": "document.md",
"document_sha256": "df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371",
"anchor": {
"kind": "marker",
"value": "ap1-direct-architecture",
"line": 152
}
},
"outputs": [
"ap1-direct-architecture.svg",
"ap1-direct-architecture.drawio",
"ap1-direct-architecture.mmd",
"ap1-direct-architecture.d2",
"ap1-direct-architecture.dot",
"ap1-direct-architecture.excalidraw",
"ap1-direct-architecture.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "component-flow",
"reference_ids": [
"payment-event-flow"
],
"diagram_only": true
}
@@ -0,0 +1,11 @@
%% AP1은 OAuth client와 token custody를 브라우저에 둔다
%% question: AP1에서 code 교환, token 보관, Resource Server JWT 입력은 어느 구성 요소에 놓이는가?
flowchart LR
n0["Public SPA"]
n1[("Browser JS memory")]
n2["Keycloak"]
n3["Resource Server"]
n0 -->|"Authorization Code + PKCE S256"| n2
n2 -->|"access · refresh · ID token"| n0
n0 -->|"token set 보관"| n1
n0 -->|"memory-held access JWT · Bearer"| n3
@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="680" height="535" viewBox="0 0 680 535" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">AP1은 OAuth client와 token custody를 브라우저에 둔다</title>
<desc id="diagram-description">왼쪽의 public SPA가 Keycloak과 Authorization Code 및 PKCE S256 계약을 수행한다. Keycloak token 응답의 access, refresh, ID token은 브라우저 JavaScript memory에 놓이며, 그중 access JWT가 오른쪽 Resource Server의 검증 입력이 된다. Resource Server는 issuer, 시간 제약과 keycloak-pattern-api audience를 검증한다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;ap1-direct-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;df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;marker&quot;,&quot;value&quot;:&quot;ap1-direct-architecture&quot;,&quot;line&quot;:152}},&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="680" height="535" />
<polyline class="edge kind-response style-dashed emphasis-normal semantic-dashed" points="498.5,444.0 498.5,490.0 145.0,490.0 145.0,296.0" data-evidence="144-148" />
<rect class="edge-label-bg" x="148.3" y="448.0" width="198.9" height="22" rx="3" />
<text class="edge-label" x="247.8" y="463.0">access · refresh · ID token</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="220.0,279.0 331.8,279.0 331.8,421.0 423.5,421.0" data-evidence="144-146" />
<rect class="edge-label-bg" x="198.2" y="326.0" width="219.0" height="22" rx="3" />
<text class="edge-label" x="307.8" y="341.0">Authorization Code + PKCE S256</text>
<polyline class="edge kind-data style-solid emphasis-normal" points="220.0,243.0 303.5,243.0 303.5,264.0 387.0,264.0" data-evidence="148-150" />
<rect class="edge-label-bg" x="278.3" y="239.5" width="98.4" height="22" rx="3" />
<text class="edge-label" x="327.5" y="254.5">token set 보관</text>
<polyline class="edge kind-request style-solid emphasis-primary" points="220.0,225.0 300.0,225.0 300.0,104.0 380.0,104.0" data-evidence="144-150" />
<rect class="edge-label-bg" x="211.1" y="150.5" width="225.7" height="22" rx="3" />
<text class="edge-label" x="324.0" y="165.5">memory-held access JWT · Bearer</text>
<g id="node-browser-spa">
<rect class="node-shape kind-service emphasis-primary role-source" data-evidence="144-148" x="70.0" y="208.0" width="150.0" height="88.0" rx="7" />
<text class="node-label" x="145.0" y="235.0">Public SPA</text>
<line class="node-detail-divider" x1="84.0" y1="256.0" x2="206.0" y2="256.0" />
<text class="node-detail" x="86.0" y="273.0">spa-public</text>
<text class="node-detail" x="86.0" y="289.0">PKCE S256</text>
</g>
<g id="node-api-resource-server">
<rect class="node-shape kind-service emphasis-normal role-sink" data-evidence="144-150" x="380.0" y="60.0" width="237.0" height="88.0" rx="7" />
<text class="node-label" x="498.5" y="87.0">Resource Server</text>
<line class="node-detail-divider" x1="394.0" y1="108.0" x2="603.0" y2="108.0" />
<text class="node-detail" x="396.0" y="125.0">issuer · time</text>
<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" />
<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>
<text class="node-detail" x="403.0" y="301.0">persistent Web Storage 없음</text>
</g>
<g id="node-keycloak">
<rect class="node-shape kind-service emphasis-normal role-service" data-evidence="144-146" x="423.5" y="380.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="498.5" y="410.0">Keycloak</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.4 KiB

@@ -0,0 +1,23 @@
# AP2는 refresh custody와 API caller를 서로 다른 경계에 둔다
## Alternative text
브라우저가 Spring mediator에서 access token만 받아 Resource Server를 직접 호출하고 refresh token은 authorized-client store에 남기는 AP2 split-custody 아키텍처.
## Long description
브라우저는 AP2_SESSION으로 confidential Spring mediator의 login state를 사용한다. Mediator는 code를 교환한 뒤 access와 refresh token을 server-side authorized-client service에 저장한다. 현재 access token의 값, type, expiry만 브라우저로 전달되고 refresh token은 server 경계에 남는다. 브라우저는 memory에 있는 access token으로 Resource Server용 Bearer header를 만든다.
## Elements and evidence
- **Browser** (actor): AP2_SESSION을 사용하고 전달받은 access token으로 API를 직접 호출하는 주체. Evidence: L156L160.
- **Spring mediator** (service): Confidential OAuth client로 code를 교환하고 browser에 access-only 응답을 제공한다. Evidence: L156L158.
- **Authorized-client store** (database): Access와 refresh token을 server-side state로 보관한다. Evidence: L156L160.
- **Resource Server** (service): 브라우저가 만든 Bearer header를 직접 받으며 downstream audience를 검증하는 API. Evidence: L156L160.
## Relationships
- **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.
@@ -0,0 +1,19 @@
# AP2는 refresh custody와 API caller를 서로 다른 경계에 둔다
# Question: AP2에서 server-held refresh token과 browser-held access token은 어떻게 Resource Server 직접 호출로 이어지는가?
direction: right
n0: "Browser" {
shape: person
}
n1: "Spring mediator" {
shape: rectangle
}
n2: "Authorized-client store" {
shape: sql_table
}
n3: "Resource Server" {
shape: rectangle
}
n0 -> n1: "AP2_SESSION"
n1 -> n2: "보관 · 현재 access 조회"
n1 -> n0: "access_token · type · expiry"
n0 -> n3: "Bearer header"
@@ -0,0 +1,13 @@
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="Browser", shape=box, style="rounded,dashed,filled"];
n1 [label="Spring mediator", shape=box, style="rounded,filled"];
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 -> n0 [label="access_token · type · expiry", style=solid];
n0 -> n3 [label="Bearer header", style=solid];
}
@@ -0,0 +1,43 @@
<?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="ap2-mediator-architecture" name="AP2는 refresh custody와 API caller를 서로 다른 경계에 둔다">
<mxGraphModel dx="925" dy="393" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="925" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_browser" value="Browser&lt;br/&gt;HttpOnly AP2_SESSION&lt;br/&gt;access token in memory" tooltip="AP2_SESSION을 사용하고 전달받은 access token으로 API를 직접 호출하는 주체. | Evidence: L156-L160" 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="128.0" width="170.0" height="88.0" as="geometry"/>
</mxCell>
<mxCell id="n_api-resource-server" value="Resource Server" tooltip="브라우저가 만든 Bearer header를 직접 받으며 downstream audience를 검증하는 API. | 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="408.5" y="60.0" width="150.0" height="64.0" as="geometry"/>
</mxCell>
<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">
<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">
<mxGeometry relative="1" as="geometry">
<mxPoint x="348.2" y="123.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_browser-to-mediator" value="AP2_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_mediator">
<mxGeometry relative="1" as="geometry">
<mxPoint x="344.0" y="201.5" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_mediator-to-browser" value="access_token · type · expiry" 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_browser">
<mxGeometry relative="1" as="geometry">
<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">
<mxGeometry relative="1" as="geometry">
<mxPoint x="671.0" y="206.0" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,685 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "edge-browser-to-api",
"type": "arrow",
"x": 240.0,
"y": 92.0,
"width": 168.5,
"height": 62.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 483845717,
"version": 1,
"versionNonce": 315895803,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
62.0
],
[
84.25,
62.0
],
[
84.25,
0.0
],
[
168.5,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-browser",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-api-resource-server",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-browser-to-api",
"type": "text",
"x": 296.25,
"y": 111.0,
"width": 104,
"height": 24,
"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": 505204354,
"version": 1,
"versionNonce": 1198512764,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "Bearer header",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Bearer header",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-browser-to-mediator",
"type": "arrow",
"x": 240.0,
"y": 172.0,
"width": 160.0,
"height": 59.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 158073357,
"version": 1,
"versionNonce": 843980499,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
80.0,
0.0
],
[
80.0,
59.0
],
[
160.0,
59.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-browser",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-mediator",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-browser-to-mediator",
"type": "text",
"x": 299.0,
"y": 189.5,
"width": 90,
"height": 24,
"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": 969862946,
"version": 1,
"versionNonce": 816956638,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "AP2_SESSION",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "AP2_SESSION",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-mediator-to-browser",
"type": "arrow",
"x": 167.0,
"y": 216.0,
"width": 328.5,
"height": 132.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1097322181,
"version": 1,
"versionNonce": 162636169,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
328.5,
68.0
],
[
328.5,
132.0
],
[
0.0,
132.0
],
[
0.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-mediator",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-browser",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-mediator-to-browser",
"type": "text",
"x": 185.25,
"y": 364.0,
"width": 224,
"height": 24,
"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": 337989394,
"version": 1,
"versionNonce": 498879847,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "access_token · type · expiry",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "access_token · type · expiry",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-mediator-to-store",
"type": "arrow",
"x": 567.0,
"y": 172.0,
"width": 160.0,
"height": 68.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1992079740,
"version": 1,
"versionNonce": 539106131,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
68.0
],
[
80.0,
68.0
],
[
80.0,
0.0
],
[
160.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-mediator",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-authorized-client-store",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-mediator-to-store",
"type": "text",
"x": 603.0,
"y": 194.0,
"width": 136,
"height": 24,
"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": 1654668376,
"version": 1,
"versionNonce": 26635985,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "보관 · 현재 access 조회",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "보관 · 현재 access 조회",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-browser",
"type": "rectangle",
"x": 70.0,
"y": 128.0,
"width": 170.0,
"height": 88.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1549664096,
"version": 1,
"versionNonce": 397227867,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-browser",
"type": "text",
"x": 80.0,
"y": 138.0,
"width": 150.0,
"height": 68.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": 225442297,
"version": 1,
"versionNonce": 1441680053,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Browser\nHttpOnly AP2_SESSION\naccess token in memory",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Browser\nHttpOnly AP2_SESSION\naccess token in memory",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-api-resource-server",
"type": "rectangle",
"x": 408.5,
"y": 60.0,
"width": 150.0,
"height": 64.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": 1241561907,
"version": 1,
"versionNonce": 950374087,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-api-resource-server",
"type": "text",
"x": 418.5,
"y": 70.0,
"width": 130.0,
"height": 44.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": 138484242,
"version": 1,
"versionNonce": 528224855,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Resource Server",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Resource Server",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-mediator",
"type": "rectangle",
"x": 400.0,
"y": 196.0,
"width": 167.0,
"height": 88.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": 846864641,
"version": 1,
"versionNonce": 308227677,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-mediator",
"type": "text",
"x": 410.0,
"y": 206.0,
"width": 147.0,
"height": 68.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": 597421764,
"version": 1,
"versionNonce": 193256573,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Spring mediator\noauth2Login\nconfidential client",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Spring mediator\noauth2Login\nconfidential client",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-authorized-client-store",
"type": "rectangle",
"x": 727.0,
"y": 118.0,
"width": 153.0,
"height": 108.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#e7f5ff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1233315239,
"version": 1,
"versionNonce": 1888174234,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-authorized-client-store",
"type": "text",
"x": 737.0,
"y": 128.0,
"width": 133.0,
"height": 88.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": 448517001,
"version": 1,
"versionNonce": 123295628,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Authorized-client store\naccess token\nrefresh token",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Authorized-client store\naccess token\nrefresh token",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -0,0 +1,32 @@
{
"harness_version": "0.2.0",
"spec_id": "ap2-mediator-architecture",
"spec_version": "1.1",
"spec_sha256": "00183e9d9c71bd0743bb35922fe89898c7cce05f9b13492d1b38fe98be41ba1d",
"source_context": {
"document": "document.md",
"document_sha256": "df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371",
"anchor": {
"kind": "marker",
"value": "ap2-mediator-architecture",
"line": 162
}
},
"outputs": [
"ap2-mediator-architecture.svg",
"ap2-mediator-architecture.drawio",
"ap2-mediator-architecture.mmd",
"ap2-mediator-architecture.d2",
"ap2-mediator-architecture.dot",
"ap2-mediator-architecture.excalidraw",
"ap2-mediator-architecture.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "component-flow",
"reference_ids": [
"payment-event-flow"
],
"diagram_only": true
}
@@ -0,0 +1,13 @@
%% AP2는 refresh custody와 API caller를 서로 다른 경계에 둔다
%% question: AP2에서 server-held refresh token과 browser-held access token은 어떻게 Resource Server 직접 호출로 이어지는가?
flowchart LR
n0(["Browser"])
n1["Spring mediator"]
n2[("Authorized-client store")]
n3["Resource Server"]
n0 -->|"AP2_SESSION"| n1
n1 -->|"보관 · 현재 access 조회"| n2
n1 -->|"access_token · type · expiry"| n0
n0 -->|"Bearer header"| n3
classDef external stroke-dasharray: 6 4
class n0 external
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="925" height="393" viewBox="0 0 925 393" role="img" aria-labelledby="diagram-title diagram-description">
<title id="diagram-title">AP2는 refresh custody와 API caller를 서로 다른 경계에 둔다</title>
<desc id="diagram-description">브라우저는 AP2_SESSION으로 confidential Spring mediator의 login state를 사용한다. Mediator는 code를 교환한 뒤 access와 refresh token을 server-side authorized-client service에 저장한다. 현재 access token의 값, type, expiry만 브라우저로 전달되고 refresh token은 server 경계에 남는다. 브라우저는 memory에 있는 access token으로 Resource Server용 Bearer header를 만든다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;ap2-mediator-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;df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371&quot;,&quot;anchor&quot;:{&quot;kind&quot;:&quot;marker&quot;,&quot;value&quot;:&quot;ap2-mediator-architecture&quot;,&quot;line&quot;:162}},&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="925" height="393" />
<polyline class="edge kind-request style-solid emphasis-primary" points="240.0,154.0 324.2,154.0 324.2,92.0 408.5,92.0" data-evidence="158-160" />
<rect class="edge-label-bg" x="295.7" y="109.0" width="105.1" height="22" rx="3" />
<text class="edge-label" x="348.2" y="124.0">Bearer header</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="240.0,172.0 320.0,172.0 320.0,231.0 400.0,231.0" data-evidence="158-160" />
<rect class="edge-label-bg" x="298.1" y="187.5" width="91.7" height="22" rx="3" />
<text class="edge-label" x="344.0" y="202.5">AP2_SESSION</text>
<polyline class="edge kind-response style-dashed emphasis-primary semantic-dashed" points="495.5,284.0 495.5,348.0 167.0,348.0 167.0,216.0" data-evidence="158-158" />
<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>
<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>
<text class="node-label" x="155.0" y="209.0">Browser</text>
</g>
<g id="node-api-resource-server">
<rect class="node-shape kind-service emphasis-normal role-sink" data-evidence="156-160" x="408.5" y="60.0" width="150.0" height="64.0" rx="7" />
<text class="node-label" x="483.5" y="90.0">Resource Server</text>
</g>
<g id="node-mediator">
<rect class="node-shape kind-service emphasis-primary role-service" data-evidence="156-158" x="400.0" y="196.0" width="167.0" height="88.0" rx="7" />
<text class="node-label" x="483.5" y="223.0">Spring mediator</text>
<line class="node-detail-divider" x1="414.0" y1="244.0" x2="553.0" y2="244.0" />
<text class="node-detail" x="416.0" y="261.0">oauth2Login</text>
<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" />
<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" />
<text class="node-detail" x="743.0" y="201.0">access token</text>
<text class="node-detail" x="743.0" y="217.0">refresh token</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.5 KiB

@@ -0,0 +1,25 @@
# AP2 server-held token에서 브라우저 Bearer 요청까지
## Alternative text
브라우저, Spring mediator, authorized-client store, Resource Server 사이에서 AP2_SESSION 요청, access-only 응답, 브라우저 Bearer 호출과 JSON 응답이 이어지는 순서도.
## Long description
브라우저가 AP2_SESSION cookie와 함께 /token/access를 Spring mediator에 요청한다. Mediator는 현재 principal과 keycloak registration으로 authorized-client store에서 token을 조회하고 access token, type, expiry만 응답한다. 브라우저는 access token을 지역 변수로 받아 Authorization Bearer header를 만들고 Resource Server의 /api/me를 직접 호출한 뒤 사용자 JSON을 받는다. Refresh token은 브라우저 응답에 포함되지 않는다.
## Elements and evidence
- **브라우저** (participant): AP2_SESSION으로 access endpoint를 호출하고 반환된 access token으로 Resource Server를 직접 호출한다. Evidence: L505L511, L558L586.
- **Spring mediator** (participant): 현재 principal로 authorized client를 조회하고 access token, type, expiry만 JSON으로 반환한다. Evidence: L503L540.
- **Authorized-client store** (participant): Registration과 principal name으로 access token과 refresh token을 보관하는 process-local service. Evidence: L445L457, L513L521.
- **Resource Server** (participant): 브라우저가 만든 Bearer JWT를 검증하고 /api/me 사용자 JSON을 반환한다. Evidence: L571L599.
## Relationships
- **브라우저 → Spring mediator:** GET /token/access + AP2_SESSION. Evidence: L503L511.
- **Spring mediator → Authorized-client store:** authorize current principal. Evidence: L513L521.
- **Authorized-client store → Spring mediator:** current access token. Evidence: L513L521.
- **Spring mediator → 브라우저:** access token + type + expiry. Evidence: L523L540.
- **브라우저 → Resource Server:** GET /api/me · browser-created Bearer. Evidence: L558L588.
- **Resource Server → 브라우저:** subject · username · issuer · audience. Evidence: L590L615.
@@ -0,0 +1,21 @@
# AP2 server-held token에서 브라우저 Bearer 요청까지
# Question: AP2에서 AP2_SESSION으로 찾은 server-side authorized client는 어떻게 브라우저의 직접 Bearer API 호출이 되는가?
direction: right
n0: "브라우저" {
shape: rectangle
}
n1: "Spring mediator" {
shape: rectangle
}
n2: "Authorized-client store" {
shape: rectangle
}
n3: "Resource Server" {
shape: rectangle
}
n0 -> n1: "GET /token/access + AP2_SESSION"
n1 -> n2: "authorize current principal"
n2 -> n1: "current access token"
n1 -> n0: "access token + type + expiry"
n0 -> n3: "GET /api/me · browser-created Bearer"
n3 -> n0: "subject · username · issuer · audience"
@@ -0,0 +1,15 @@
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="브라우저", shape=box, style="rounded,filled"];
n1 [label="Spring mediator", shape=box, style="rounded,filled"];
n2 [label="Authorized-client store", shape=box, style="rounded,filled"];
n3 [label="Resource Server", shape=box, style="rounded,filled"];
n0 -> n1 [label="GET /token/access + AP2_SESSION", style=solid];
n1 -> n2 [label="authorize current principal", style=solid];
n2 -> n1 [label="current access token", style=solid];
n1 -> n0 [label="access token + type + expiry", style=solid];
n0 -> n3 [label="GET /api/me · browser-created Bearer", style=solid];
n3 -> n0 [label="subject · username · issuer · audience", style=solid];
}
@@ -0,0 +1,53 @@
<?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="ap2-mediator-handoff-flow" name="AP2 server-held token에서 브라우저 Bearer 요청까지">
<mxGraphModel dx="870" dy="542" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="870" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_browser" value="브라우저" tooltip="AP2_SESSION으로 access endpoint를 호출하고 반환된 access token으로 Resource Server를 직접 호출한다. | Evidence: L505-L511, L558-L586" 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="35.0" width="150.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="n_mediator" value="Spring mediator" tooltip="현재 principal로 authorized client를 조회하고 access token, type, expiry만 JSON으로 반환한다. | Evidence: L503-L540" 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="255.0" y="35.0" width="150.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="n_authorized-client-store" value="Authorized-client store" tooltip="Registration과 principal name으로 access token과 refresh token을 보관하는 process-local service. | Evidence: L445-L457, L513-L521" 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="465.0" y="35.0" width="153.0" height="74.0" as="geometry"/>
</mxCell>
<mxCell id="n_resource-server" value="Resource Server" tooltip="브라우저가 만든 Bearer JWT를 검증하고 /api/me 사용자 JSON을 반환한다. | Evidence: L571-L599" 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="675.0" y="35.0" width="150.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="e_session-request" value="GET /token/access + AP2_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_mediator">
<mxGeometry relative="1" as="geometry">
<mxPoint x="225.0" y="128.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_authorize-client" value="authorize current principal" 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="435.8" y="190.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_load-access-token" value="current access token" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_authorized-client-store" target="n_mediator">
<mxGeometry relative="1" as="geometry">
<mxPoint x="435.8" y="252.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_access-only-response" value="access token + type + expiry" 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_browser">
<mxGeometry relative="1" as="geometry">
<mxPoint x="225.0" y="314.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_bearer-request" value="GET /api/me · browser-created Bearer" 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_resource-server">
<mxGeometry relative="1" as="geometry">
<mxPoint x="435.0" y="376.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_identity-response" value="subject · username · issuer · audience" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_resource-server" target="n_browser">
<mxGeometry relative="1" as="geometry">
<mxPoint x="435.0" y="438.0" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,835 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "edge-session-request",
"type": "arrow",
"x": 120.0,
"y": 140.0,
"width": 210.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1902292152,
"version": 1,
"versionNonce": 345538445,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
210.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-browser",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-mediator",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-session-request",
"type": "text",
"x": 101.0,
"y": 116.0,
"width": 248,
"height": 24,
"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": 588846210,
"version": 1,
"versionNonce": 1756427408,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "GET /token/access + AP2_SESSION",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "GET /token/access + AP2_SESSION",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-authorize-client",
"type": "arrow",
"x": 330.0,
"y": 202.0,
"width": 211.5,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 78945890,
"version": 1,
"versionNonce": 41973652,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
211.5,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-mediator",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-authorized-client-store",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-authorize-client",
"type": "text",
"x": 327.75,
"y": 178.0,
"width": 216,
"height": 24,
"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": 1777807805,
"version": 1,
"versionNonce": 1875848801,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "authorize current principal",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "authorize current principal",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-load-access-token",
"type": "arrow",
"x": 330.0,
"y": 264.0,
"width": 211.5,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 269865813,
"version": 1,
"versionNonce": 1492579220,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
211.5,
0.0
],
[
0.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-authorized-client-store",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-mediator",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-load-access-token",
"type": "text",
"x": 355.75,
"y": 240.0,
"width": 160,
"height": 24,
"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": 1608333106,
"version": 1,
"versionNonce": 399100329,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "current access token",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "current access token",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-access-only-response",
"type": "arrow",
"x": 120.0,
"y": 326.0,
"width": 210.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 177492918,
"version": 1,
"versionNonce": 1089375525,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
210.0,
0.0
],
[
0.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-mediator",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-browser",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-access-only-response",
"type": "text",
"x": 113.0,
"y": 302.0,
"width": 224,
"height": 24,
"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": 1446063633,
"version": 1,
"versionNonce": 217683732,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "access token + type + expiry",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "access token + type + expiry",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-bearer-request",
"type": "arrow",
"x": 120.0,
"y": 388.0,
"width": 630.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1947233291,
"version": 1,
"versionNonce": 103967140,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
630.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-browser",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-resource-server",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-bearer-request",
"type": "text",
"x": 291.0,
"y": 364.0,
"width": 288,
"height": 24,
"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": 1747514944,
"version": 1,
"versionNonce": 1356392748,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "GET /api/me · browser-created Bearer",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "GET /api/me · browser-created Bearer",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-identity-response",
"type": "arrow",
"x": 120.0,
"y": 450.0,
"width": 630.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 507094579,
"version": 1,
"versionNonce": 359980803,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
630.0,
0.0
],
[
0.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-resource-server",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-browser",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-identity-response",
"type": "text",
"x": 283.0,
"y": 426.0,
"width": 304,
"height": 24,
"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": 1554376528,
"version": 1,
"versionNonce": 231361274,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "subject · username · issuer · audience",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "subject · username · issuer · audience",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-browser",
"type": "rectangle",
"x": 45.0,
"y": 35.0,
"width": 150.0,
"height": 64.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": 1549664096,
"version": 1,
"versionNonce": 397227867,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-browser",
"type": "text",
"x": 55.0,
"y": 45.0,
"width": 130.0,
"height": 44.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": 225442297,
"version": 1,
"versionNonce": 1441680053,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "브라우저",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "브라우저",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-mediator",
"type": "rectangle",
"x": 255.0,
"y": 35.0,
"width": 150.0,
"height": 64.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": 846864641,
"version": 1,
"versionNonce": 308227677,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-mediator",
"type": "text",
"x": 265.0,
"y": 45.0,
"width": 130.0,
"height": 44.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": 597421764,
"version": 1,
"versionNonce": 193256573,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Spring mediator",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Spring mediator",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-authorized-client-store",
"type": "rectangle",
"x": 465.0,
"y": 35.0,
"width": 153.0,
"height": 74.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": 1233315239,
"version": 1,
"versionNonce": 1888174234,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-authorized-client-store",
"type": "text",
"x": 475.0,
"y": 45.0,
"width": 133.0,
"height": 54.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": 448517001,
"version": 1,
"versionNonce": 123295628,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Authorized-client store",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Authorized-client store",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-resource-server",
"type": "rectangle",
"x": 675.0,
"y": 35.0,
"width": 150.0,
"height": 64.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": 972285001,
"version": 1,
"versionNonce": 1174270881,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-resource-server",
"type": "text",
"x": 685.0,
"y": 45.0,
"width": 130.0,
"height": 44.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": 1517556067,
"version": 1,
"versionNonce": 178679525,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Resource Server",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Resource Server",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -0,0 +1,32 @@
{
"harness_version": "0.2.0",
"spec_id": "ap2-mediator-handoff-flow",
"spec_version": "1.1",
"spec_sha256": "b129ffe1dee64fe9ee2f29d59ad47472ae9fe9511c92f3199fa053ba0c3b2c3f",
"source_context": {
"document": "document.md",
"document_sha256": "df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371",
"anchor": {
"kind": "marker",
"value": "ap2-mediator-handoff-flow",
"line": 645
}
},
"outputs": [
"ap2-mediator-handoff-flow.svg",
"ap2-mediator-handoff-flow.drawio",
"ap2-mediator-handoff-flow.mmd",
"ap2-mediator-handoff-flow.d2",
"ap2-mediator-handoff-flow.dot",
"ap2-mediator-handoff-flow.excalidraw",
"ap2-mediator-handoff-flow.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "sequence",
"reference_ids": [
"payment-approval-sequence"
],
"diagram_only": true
}
@@ -0,0 +1,13 @@
%% AP2 server-held token에서 브라우저 Bearer 요청까지
%% question: AP2에서 AP2_SESSION으로 찾은 server-side authorized client는 어떻게 브라우저의 직접 Bearer API 호출이 되는가?
sequenceDiagram
participant n0 as 브라우저
participant n1 as Spring mediator
participant n2 as Authorized-client store
participant n3 as Resource Server
n0->>n1: GET /token/access + AP2_SESSION
n1->>n2: authorize current principal
n2->>n1: current access token
n1->>n0: access token + type + expiry
n0->>n3: GET /api/me · browser-created Bearer
n3->>n0: subject · username · issuer · audience
@@ -0,0 +1,84 @@
<?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">AP2 server-held token에서 브라우저 Bearer 요청까지</title>
<desc id="diagram-description">브라우저가 AP2_SESSION cookie와 함께 /token/access를 Spring mediator에 요청한다. Mediator는 현재 principal과 keycloak registration으로 authorized-client store에서 token을 조회하고 access token, type, expiry만 응답한다. 브라우저는 access token을 지역 변수로 받아 Authorization Bearer header를 만들고 Resource Server의 /api/me를 직접 호출한 뒤 사용자 JSON을 받는다. Refresh token은 브라우저 응답에 포함되지 않는다.</desc>
<metadata>{&quot;techviz&quot;:{&quot;spec_version&quot;:&quot;1.1&quot;,&quot;id&quot;:&quot;ap2-mediator-handoff-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;ap2-mediator-handoff-flow&quot;,&quot;line&quot;:645}},&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="505-511,558-586" 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="503-540" 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 mediator</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="445-457,513-521" 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="571-599" 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="503-511" />
<rect class="edge-label-bg" x="102.1" y="114.0" width="245.8" height="22" rx="3" />
<text class="edge-label" x="225.0" y="129.0">1. GET /token/access + AP2_SESSION</text>
<polyline class="edge kind-request style-solid emphasis-normal" points="330.0,202.0 541.5,202.0" data-evidence="513-521" />
<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="513-521" />
<rect class="edge-label-bg" x="349.7" y="238.0" width="172.1" height="22" rx="3" />
<text class="edge-label" x="435.8" y="253.0">3. current access token</text>
<polyline class="edge kind-response style-dashed emphasis-normal semantic-dashed" points="330.0,326.0 120.0,326.0" data-evidence="523-540" />
<rect class="edge-label-bg" x="112.1" y="300.0" width="225.7" height="22" rx="3" />
<text class="edge-label" x="225.0" y="315.0">4. access token + type + expiry</text>
<polyline class="edge kind-request style-solid emphasis-primary" points="120.0,388.0 750.0,388.0" data-evidence="558-588" />
<rect class="edge-label-bg" x="295.4" y="362.0" width="279.3" height="22" rx="3" />
<text class="edge-label" x="435.0" y="377.0">5. GET /api/me · browser-created Bearer</text>
<polyline class="edge kind-response style-dashed emphasis-normal semantic-dashed" points="750.0,450.0 120.0,450.0" data-evidence="590-615" />
<rect class="edge-label-bg" x="288.6" y="424.0" width="292.7" height="22" rx="3" />
<text class="edge-label" x="435.0" y="439.0">6. subject · username · issuer · audience</text>
</svg>

After

Width:  |  Height:  |  Size: 7.5 KiB

@@ -0,0 +1,24 @@
# AP3는 browser session을 BFF-owned Bearer 요청으로 바꾼다
## Alternative text
Browser session zone과 server-side BFF zone 사이에서 AP3_SESSION이 downstream Bearer 요청으로 바뀌는 BFF 아키텍처.
## Long 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 요청을 조립한다.
## 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.
## 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.
@@ -0,0 +1,22 @@
# AP3는 browser session을 BFF-owned Bearer 요청으로 바꾼다
# Question: AP3에서 OAuth token 없는 브라우저 요청은 어느 경계를 지나 내부 Resource Server Bearer 요청이 되는가?
direction: right
g0: "Browser session boundary" {
n0: "Browser" {
shape: person
}
}
g1: "Server-side application path" {
n1: "Spring BFF" {
shape: rectangle
}
n2: "Authorized-client store" {
shape: sql_table
}
n3: "Internal Resource Server" {
shape: rectangle
}
}
g0.n0 -> g1.n1: "AP3_SESSION · /bff/api/me"
g1.n1 -> g1.n2: "현재 authorized client 조회"
g1.n1 -> g1.n3: "downstream Bearer 요청"
@@ -0,0 +1,22 @@
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];
subgraph cluster_0 {
label="Browser session boundary";
style="rounded,dashed";
color="#66788a";
n0 [label="Browser", shape=box, style="rounded,dashed,filled"];
}
subgraph cluster_1 {
label="Server-side application path";
style="rounded,dashed";
color="#66788a";
n1 [label="Spring BFF", shape=box, style="rounded,filled"];
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];
}
@@ -0,0 +1,44 @@
<?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">
<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"/>
</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"/>
</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>
<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>
<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>
<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>
<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">
<mxGeometry relative="1" as="geometry">
<mxPoint x="714.0" y="212.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">
<mxGeometry relative="1" as="geometry">
<mxPoint x="790.5" y="31.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">
<mxGeometry relative="1" as="geometry">
<mxPoint x="430.0" y="111.0" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,732 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "group-browser-session-zone",
"type": "rectangle",
"x": 45.0,
"y": 49.0,
"width": 250.0,
"height": 160.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#f8f9fa",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "dashed",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 289565397,
"version": 1,
"versionNonce": 858827588,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "group-label-browser-session-zone",
"type": "text",
"x": 61.0,
"y": 55.0,
"width": 216,
"height": 24,
"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": 1429919230,
"version": 1,
"versionNonce": 786395767,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 14,
"fontFamily": 5,
"text": "Browser session boundary",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Browser session boundary",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "group-server-application-zone",
"type": "rectangle",
"x": 565.0,
"y": 49.0,
"width": 470.0,
"height": 291.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#f8f9fa",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "dashed",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1445595128,
"version": 1,
"versionNonce": 1494971669,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "group-label-server-application-zone",
"type": "text",
"x": 581.0,
"y": 55.0,
"width": 252,
"height": 24,
"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": 1844725802,
"version": 1,
"versionNonce": 1914753065,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 14,
"fontFamily": 5,
"text": "Server-side application path",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Server-side application path",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-bff-to-resource-server",
"type": "arrow",
"x": 595.0,
"y": 148.0,
"width": 190.0,
"height": 129.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 308435246,
"version": 1,
"versionNonce": 268045461,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
190.0,
0.0
],
[
95.0,
0.0
],
[
95.0,
129.0
],
[
0.0,
129.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-bff",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-resource-server",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-bff-to-resource-server",
"type": "text",
"x": 634.0,
"y": 200.5,
"width": 160,
"height": 24,
"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": 40927203,
"version": 1,
"versionNonce": 1470596567,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "downstream Bearer 요청",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "downstream 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,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 866282748,
"version": 1,
"versionNonce": 697646849,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
71.0
],
[
30.0,
71.0
],
[
30.0,
0.0
],
[
0.0,
0.0
],
[
0.0,
90.0
],
[
30.0,
90.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-bff",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-authorized-client-store",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-bff-to-store",
"type": "text",
"x": 698.5,
"y": 19.0,
"width": 184,
"height": 24,
"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": 1596876379,
"version": 1,
"versionNonce": 1494847814,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "현재 authorized client 조회",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "현재 authorized client 조회",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-browser-to-bff",
"type": "arrow",
"x": 265.0,
"y": 139.0,
"width": 330.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 269513575,
"version": 1,
"versionNonce": 1064149919,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
165.0,
0.0
],
[
165.0,
0.0
],
[
330.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-browser",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-bff",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-browser-to-bff",
"type": "text",
"x": 330.0,
"y": 99.0,
"width": 200,
"height": 24,
"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": 1975756781,
"version": 1,
"versionNonce": 1233422614,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "AP3_SESSION · /bff/api/me",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "AP3_SESSION · /bff/api/me",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-browser",
"type": "rectangle",
"x": 75.0,
"y": 95.0,
"width": 190.0,
"height": 88.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1549664096,
"version": 1,
"versionNonce": 397227867,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-browser",
"type": "text",
"x": 85.0,
"y": 105.0,
"width": 170.0,
"height": 68.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": 225442297,
"version": 1,
"versionNonce": 1441680053,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Browser\nHttpOnly AP3_SESSION\nOAuth token 없음",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Browser\nHttpOnly AP3_SESSION\nOAuth token 없음",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-bff",
"type": "rectangle",
"x": 595.0,
"y": 95.0,
"width": 190.0,
"height": 88.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": 734504749,
"version": 1,
"versionNonce": 817081613,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-bff",
"type": "text",
"x": 605.0,
"y": 105.0,
"width": 170.0,
"height": 68.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": 950295209,
"version": 1,
"versionNonce": 1878756486,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Spring BFF\nbff-confidential\noauth2Login",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Spring BFF\nbff-confidential\noauth2Login",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-authorized-client-store",
"type": "rectangle",
"x": 815.0,
"y": 95.0,
"width": 190.0,
"height": 108.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#e7f5ff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1233315239,
"version": 1,
"versionNonce": 1888174234,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-authorized-client-store",
"type": "text",
"x": 825.0,
"y": 105.0,
"width": 170.0,
"height": 88.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": 448517001,
"version": 1,
"versionNonce": 123295628,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Authorized-client store\naccess token\nrefresh token",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Authorized-client store\naccess token\nrefresh token",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-resource-server",
"type": "rectangle",
"x": 595.0,
"y": 240.0,
"width": 190.0,
"height": 74.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": 972285001,
"version": 1,
"versionNonce": 1174270881,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-resource-server",
"type": "text",
"x": 605.0,
"y": 250.0,
"width": 170.0,
"height": 54.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": 1517556067,
"version": 1,
"versionNonce": 178679525,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Internal Resource Server",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Internal Resource Server",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -0,0 +1,32 @@
{
"harness_version": "0.2.0",
"spec_id": "ap3-bff-architecture",
"spec_version": "1.1",
"spec_sha256": "20ed427be186390406589d69a75b848def944906ab94ff5b7448ead627b95bb2",
"source_context": {
"document": "document.md",
"document_sha256": "df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371",
"anchor": {
"kind": "marker",
"value": "ap3-bff-architecture",
"line": 172
}
},
"outputs": [
"ap3-bff-architecture.svg",
"ap3-bff-architecture.drawio",
"ap3-bff-architecture.mmd",
"ap3-bff-architecture.d2",
"ap3-bff-architecture.dot",
"ap3-bff-architecture.excalidraw",
"ap3-bff-architecture.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "two-zone-pipeline",
"reference_ids": [
"localization-pipeline"
],
"diagram_only": true
}
@@ -0,0 +1,16 @@
%% AP3는 browser session을 BFF-owned Bearer 요청으로 바꾼다
%% question: AP3에서 OAuth token 없는 브라우저 요청은 어느 경계를 지나 내부 Resource Server Bearer 요청이 되는가?
flowchart LR
subgraph g_browser_session_zone["Browser session boundary"]
n0(["Browser"])
end
subgraph g_server_application_zone["Server-side application path"]
n1["Spring BFF"]
n2[("Authorized-client store")]
n3["Internal Resource Server"]
end
n0 -->|"AP3_SESSION · /bff/api/me"| n1
n1 -->|"현재 authorized client 조회"| n2
n1 -->|"downstream Bearer 요청"| n3
classDef external stroke-dasharray: 6 4
class n0 external
@@ -0,0 +1,92 @@
<?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">
<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>
<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="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>
<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>
<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>
</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>
</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>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.7 KiB

@@ -0,0 +1,25 @@
# AP3 session cookie에서 BFF downstream Bearer까지
## Alternative text
브라우저, BFF, authorized-client store, Resource Server 사이에서 AP3_SESSION 요청, server-held token 조회, downstream Bearer 호출과 중계 JSON이 이어지는 순서도.
## Long 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으로 전달되지 않는다.
## Elements and evidence
- **브라우저** (participant): Authorization header 없이 AP3_SESSION cookie로 BFF endpoint를 호출하고 중계 JSON을 받는다. Evidence: L714L722, L750L760, L796L809.
- **Spring BFF** (participant): Session authentication을 authorized client 조회로 바꾸고 Resource Server용 Bearer 요청을 조립한다. Evidence: L750L783, L796L809.
- **Authorized-client store** (participant): Registration과 principal name으로 BFF의 access token과 refresh token을 보관한다. Evidence: L700L712, L762L768.
- **Resource Server** (participant): BFF가 붙인 Bearer JWT를 검증하고 사용자 JSON을 반환한다. Evidence: L776L796.
## Relationships
- **브라우저 → Spring BFF:** GET /bff/api/me + AP3_SESSION. Evidence: L750L760.
- **Spring BFF → Authorized-client store:** authorize current principal. Evidence: L762L768.
- **Authorized-client store → Spring BFF:** server-held access token. Evidence: L762L768.
- **Spring BFF → Resource Server:** GET /api/me · Bearer access token. Evidence: L770L783.
- **Resource Server → Spring BFF:** subject · username · issuer · audience. Evidence: L785L796.
- **Spring BFF → 브라우저:** BFF ResponseEntity → browser JSON. Evidence: L796L809.
@@ -0,0 +1,21 @@
# AP3 session cookie에서 BFF downstream Bearer까지
# Question: AP3에서 브라우저의 AP3_SESSION은 어떻게 BFF가 만든 downstream Bearer 요청과 중계 JSON이 되는가?
direction: right
n0: "브라우저" {
shape: rectangle
}
n1: "Spring BFF" {
shape: rectangle
}
n2: "Authorized-client store" {
shape: rectangle
}
n3: "Resource Server" {
shape: rectangle
}
n0 -> n1: "GET /bff/api/me + AP3_SESSION"
n1 -> n2: "authorize current principal"
n2 -> n1: "server-held access token"
n1 -> n3: "GET /api/me · Bearer access token"
n3 -> n1: "subject · username · issuer · audience"
n1 -> n0: "BFF ResponseEntity → browser JSON"
@@ -0,0 +1,15 @@
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="브라우저", shape=box, style="rounded,filled"];
n1 [label="Spring BFF", shape=box, style="rounded,filled"];
n2 [label="Authorized-client store", shape=box, style="rounded,filled"];
n3 [label="Resource Server", shape=box, style="rounded,filled"];
n0 -> n1 [label="GET /bff/api/me + AP3_SESSION", style=solid];
n1 -> n2 [label="authorize current principal", style=solid];
n2 -> n1 [label="server-held access token", style=solid];
n1 -> n3 [label="GET /api/me · Bearer access token", style=solid];
n3 -> n1 [label="subject · username · issuer · audience", style=solid];
n1 -> n0 [label="BFF ResponseEntity → browser JSON", style=solid];
}
@@ -0,0 +1,53 @@
<?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-session-flow" name="AP3 session cookie에서 BFF downstream Bearer까지">
<mxGraphModel dx="870" dy="542" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="870" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_browser" value="브라우저" tooltip="Authorization header 없이 AP3_SESSION cookie로 BFF endpoint를 호출하고 중계 JSON을 받는다. | Evidence: L714-L722, L750-L760, L796-L809" 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="35.0" width="150.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="n_bff" value="Spring BFF" tooltip="Session authentication을 authorized client 조회로 바꾸고 Resource Server용 Bearer 요청을 조립한다. | Evidence: L750-L783, L796-L809" 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="255.0" y="35.0" width="150.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="n_authorized-client-store" value="Authorized-client store" tooltip="Registration과 principal name으로 BFF의 access token과 refresh token을 보관한다. | Evidence: L700-L712, L762-L768" 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="465.0" y="35.0" width="153.0" height="74.0" as="geometry"/>
</mxCell>
<mxCell id="n_resource-server" value="Resource Server" tooltip="BFF가 붙인 Bearer JWT를 검증하고 사용자 JSON을 반환한다. | Evidence: L776-L796" 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="675.0" y="35.0" width="150.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="e_session-request" value="GET /bff/api/me + 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="225.0" y="128.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_authorize-client" value="authorize current principal" 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="435.8" y="190.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_load-server-token" value="server-held access token" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_authorized-client-store" target="n_bff">
<mxGeometry relative="1" as="geometry">
<mxPoint x="435.8" y="252.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_downstream-bearer" value="GET /api/me · Bearer access token" 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="540.0" y="314.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_resource-json" value="subject · username · issuer · audience" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_resource-server" target="n_bff">
<mxGeometry relative="1" as="geometry">
<mxPoint x="540.0" y="376.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_relayed-json" value="BFF ResponseEntity → browser JSON" 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_browser">
<mxGeometry relative="1" as="geometry">
<mxPoint x="225.0" y="438.0" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,835 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "edge-session-request",
"type": "arrow",
"x": 120.0,
"y": 140.0,
"width": 210.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1902292152,
"version": 1,
"versionNonce": 345538445,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
210.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-browser",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-bff",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-session-request",
"type": "text",
"x": 109.0,
"y": 116.0,
"width": 232,
"height": 24,
"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": 588846210,
"version": 1,
"versionNonce": 1756427408,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "GET /bff/api/me + AP3_SESSION",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "GET /bff/api/me + AP3_SESSION",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-authorize-client",
"type": "arrow",
"x": 330.0,
"y": 202.0,
"width": 211.5,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 78945890,
"version": 1,
"versionNonce": 41973652,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
211.5,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-bff",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-authorized-client-store",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-authorize-client",
"type": "text",
"x": 327.75,
"y": 178.0,
"width": 216,
"height": 24,
"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": 1777807805,
"version": 1,
"versionNonce": 1875848801,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "authorize current principal",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "authorize current principal",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-load-server-token",
"type": "arrow",
"x": 330.0,
"y": 264.0,
"width": 211.5,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 910033775,
"version": 1,
"versionNonce": 138986099,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
211.5,
0.0
],
[
0.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-authorized-client-store",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-bff",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-load-server-token",
"type": "text",
"x": 339.75,
"y": 240.0,
"width": 192,
"height": 24,
"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": 101489431,
"version": 1,
"versionNonce": 503718058,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "server-held access token",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "server-held access token",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-downstream-bearer",
"type": "arrow",
"x": 330.0,
"y": 326.0,
"width": 420.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1506030255,
"version": 1,
"versionNonce": 1422986765,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
420.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-bff",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-resource-server",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-downstream-bearer",
"type": "text",
"x": 408.0,
"y": 302.0,
"width": 264,
"height": 24,
"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": 1653241205,
"version": 1,
"versionNonce": 1679575389,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "GET /api/me · Bearer access token",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "GET /api/me · Bearer access token",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-resource-json",
"type": "arrow",
"x": 330.0,
"y": 388.0,
"width": 420.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1212153275,
"version": 1,
"versionNonce": 1410893193,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
420.0,
0.0
],
[
0.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-resource-server",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-bff",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-resource-json",
"type": "text",
"x": 388.0,
"y": 364.0,
"width": 304,
"height": 24,
"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": 633745566,
"version": 1,
"versionNonce": 1641908066,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "subject · username · issuer · audience",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "subject · username · issuer · audience",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-relayed-json",
"type": "arrow",
"x": 120.0,
"y": 450.0,
"width": 210.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1437264247,
"version": 1,
"versionNonce": 1222766526,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
210.0,
0.0
],
[
0.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-bff",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-browser",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-relayed-json",
"type": "text",
"x": 93.0,
"y": 426.0,
"width": 264,
"height": 24,
"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": 1629978324,
"version": 1,
"versionNonce": 970061401,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "BFF ResponseEntity → browser JSON",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "BFF ResponseEntity → browser JSON",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-browser",
"type": "rectangle",
"x": 45.0,
"y": 35.0,
"width": 150.0,
"height": 64.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": 1549664096,
"version": 1,
"versionNonce": 397227867,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-browser",
"type": "text",
"x": 55.0,
"y": 45.0,
"width": 130.0,
"height": 44.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": 225442297,
"version": 1,
"versionNonce": 1441680053,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "브라우저",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "브라우저",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-bff",
"type": "rectangle",
"x": 255.0,
"y": 35.0,
"width": 150.0,
"height": 64.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": 734504749,
"version": 1,
"versionNonce": 817081613,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-bff",
"type": "text",
"x": 265.0,
"y": 45.0,
"width": 130.0,
"height": 44.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": 950295209,
"version": 1,
"versionNonce": 1878756486,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Spring BFF",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Spring BFF",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-authorized-client-store",
"type": "rectangle",
"x": 465.0,
"y": 35.0,
"width": 153.0,
"height": 74.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": 1233315239,
"version": 1,
"versionNonce": 1888174234,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-authorized-client-store",
"type": "text",
"x": 475.0,
"y": 45.0,
"width": 133.0,
"height": 54.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": 448517001,
"version": 1,
"versionNonce": 123295628,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Authorized-client store",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Authorized-client store",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-resource-server",
"type": "rectangle",
"x": 675.0,
"y": 35.0,
"width": 150.0,
"height": 64.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": 972285001,
"version": 1,
"versionNonce": 1174270881,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-resource-server",
"type": "text",
"x": 685.0,
"y": 45.0,
"width": 130.0,
"height": 44.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": 1517556067,
"version": 1,
"versionNonce": 178679525,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Resource Server",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Resource Server",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -0,0 +1,32 @@
{
"harness_version": "0.2.0",
"spec_id": "ap3-bff-session-flow",
"spec_version": "1.1",
"spec_sha256": "2030434c650208507b9ad33f894d4de71990b1b550fa4652cddf4a6c50141d2b",
"source_context": {
"document": "document.md",
"document_sha256": "df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371",
"anchor": {
"kind": "marker",
"value": "ap3-bff-session-flow",
"line": 908
}
},
"outputs": [
"ap3-bff-session-flow.svg",
"ap3-bff-session-flow.drawio",
"ap3-bff-session-flow.mmd",
"ap3-bff-session-flow.d2",
"ap3-bff-session-flow.dot",
"ap3-bff-session-flow.excalidraw",
"ap3-bff-session-flow.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "sequence",
"reference_ids": [
"payment-approval-sequence"
],
"diagram_only": true
}
@@ -0,0 +1,13 @@
%% AP3 session cookie에서 BFF downstream Bearer까지
%% question: AP3에서 브라우저의 AP3_SESSION은 어떻게 BFF가 만든 downstream Bearer 요청과 중계 JSON이 되는가?
sequenceDiagram
participant n0 as 브라우저
participant n1 as Spring BFF
participant n2 as Authorized-client store
participant n3 as Resource Server
n0->>n1: GET /bff/api/me + AP3_SESSION
n1->>n2: authorize current principal
n2->>n1: server-held access token
n1->>n3: GET /api/me · Bearer access token
n3->>n1: subject · username · issuer · audience
n1->>n0: BFF ResponseEntity → browser JSON
@@ -0,0 +1,84 @@
<?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>

After

Width:  |  Height:  |  Size: 7.5 KiB

@@ -0,0 +1,25 @@
# AP3의 masked CSRF 응답과 raw POST credential
## Alternative text
BFF CSRF endpoint가 raw XSRF cookie와 masked JSON token으로 분기하고, SPA가 raw cookie만 실제 POST header 값으로 사용해 Spring CSRF filter에 제출하는 데이터 흐름.
## Long 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가 일치 여부를 확인한다.
## 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.
## 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.
@@ -0,0 +1,23 @@
# AP3의 masked CSRF 응답과 raw POST credential
# Question: AP3에서 CSRF 응답의 masked token과 raw cookie 중 무엇이 실제 POST header의 data source가 되는가?
direction: right
n0: "BFF · /bff/csrf" {
shape: rectangle
}
n1: "Browser cookie · raw" {
shape: rectangle
}
n2: "JSON body · masked" {
shape: rectangle
}
n3: "SPA POST 조립" {
shape: rectangle
}
n4: "Spring CSRF filter" {
shape: rectangle
}
n0 -> n1: "Set-Cookie · raw"
n0 -> n2: "masked JSON"
n1 -> n3: "document.cookie · raw"
n2 -> n3: "headerName only"
n3 -> n4: "Cookie raw = Header raw"
@@ -0,0 +1,15 @@
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="BFF · /bff/csrf", shape=box, style="rounded,filled"];
n1 [label="Browser cookie · raw", shape=box, style="rounded,filled"];
n2 [label="JSON body · masked", shape=box, style="rounded,filled"];
n3 [label="SPA POST 조립", shape=box, style="rounded,filled"];
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];
}
@@ -0,0 +1,51 @@
<?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-csrf-boundary" name="AP3의 masked CSRF 응답과 raw POST credential">
<mxGraphModel dx="1411" dy="392" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1411" pageHeight="1169" math="0" shadow="0">
<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">
<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">
<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">
<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">
<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">
<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">
<mxGeometry relative="1" as="geometry">
<mxPoint x="1070.0" y="175.5" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_endpoint-to-masked-json" value="masked JSON" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_csrf-endpoint" target="n_masked-json">
<mxGeometry relative="1" as="geometry">
<mxPoint x="324.0" y="154.8" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_endpoint-to-raw-cookie" value="Set-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_csrf-endpoint" target="n_raw-cookie">
<mxGeometry relative="1" as="geometry">
<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">
<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">
<mxGeometry relative="1" as="geometry">
<mxPoint x="694.2" y="253.5" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,853 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "edge-assembler-to-filter",
"type": "arrow",
"x": 990.0,
"y": 203.5,
"width": 160.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 722742849,
"version": 1,
"versionNonce": 364630411,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
80.0,
0.0
],
[
80.0,
0.0
],
[
160.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-post-assembler",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-csrf-filter",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-assembler-to-filter",
"type": "text",
"x": 978.0,
"y": 163.5,
"width": 184,
"height": 24,
"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": 337637491,
"version": 1,
"versionNonce": 1382239179,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "Cookie raw = Header raw",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Cookie raw = Header raw",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-endpoint-to-masked-json",
"type": "arrow",
"x": 220.0,
"y": 115.0,
"width": 160.0,
"height": 79.5,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1241633364,
"version": 1,
"versionNonce": 795935518,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
79.5
],
[
80.0,
79.5
],
[
80.0,
0.0
],
[
160.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-csrf-endpoint",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-masked-json",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-endpoint-to-masked-json",
"type": "text",
"x": 279.0,
"y": 142.75,
"width": 90,
"height": 24,
"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": 367644097,
"version": 1,
"versionNonce": 1658155106,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "masked JSON",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "masked JSON",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-endpoint-to-raw-cookie",
"type": "arrow",
"x": 220.0,
"y": 212.5,
"width": 179.5,
"height": 82.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 972011859,
"version": 1,
"versionNonce": 1702387953,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
89.75,
0.0
],
[
89.75,
82.0
],
[
179.5,
82.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-csrf-endpoint",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-raw-cookie",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-endpoint-to-raw-cookie",
"type": "text",
"x": 269.75,
"y": 241.5,
"width": 128,
"height": 24,
"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": 909441863,
"version": 1,
"versionNonce": 329759881,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "Set-Cookie · raw",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Set-Cookie · raw",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-masked-json-to-assembler",
"type": "arrow",
"x": 600.0,
"y": 115.0,
"width": 160.0,
"height": 79.5,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1214135570,
"version": 1,
"versionNonce": 472532934,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
80.0,
0.0
],
[
80.0,
79.5
],
[
160.0,
79.5
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-masked-json",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-post-assembler",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-masked-json-to-assembler",
"type": "text",
"x": 644.0,
"y": 142.75,
"width": 120,
"height": 24,
"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": 200978920,
"version": 1,
"versionNonce": 1107821704,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "headerName only",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "headerName only",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-raw-cookie-to-assembler",
"type": "arrow",
"x": 580.5,
"y": 212.5,
"width": 179.5,
"height": 82.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1497085764,
"version": 1,
"versionNonce": 1398757375,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
82.0
],
[
89.75,
82.0
],
[
89.75,
0.0
],
[
179.5,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-raw-cookie",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-post-assembler",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-raw-cookie-to-assembler",
"type": "text",
"x": 610.25,
"y": 241.5,
"width": 168,
"height": 24,
"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": 1946436355,
"version": 1,
"versionNonce": 849590995,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "document.cookie · raw",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "document.cookie · raw",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-csrf-endpoint",
"type": "rectangle",
"x": 70.0,
"y": 171.5,
"width": 150.0,
"height": 64.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": 984730913,
"version": 1,
"versionNonce": 1093344604,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-csrf-endpoint",
"type": "text",
"x": 80.0,
"y": 181.5,
"width": 130.0,
"height": 44.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": 1042231595,
"version": 1,
"versionNonce": 1571377378,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "BFF · /bff/csrf",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "BFF · /bff/csrf",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-masked-json",
"type": "rectangle",
"x": 380.0,
"y": 60.0,
"width": 220.0,
"height": 110.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": 650571311,
"version": 1,
"versionNonce": 1000688126,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-masked-json",
"type": "text",
"x": 390.0,
"y": 70.0,
"width": 200.0,
"height": 90.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": 807523454,
"version": 1,
"versionNonce": 1583912655,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "JSON body · masked\ntoken = XOR/Base64\nheaderName metadata\nPOST token 값으로 미사용",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "JSON body · masked\ntoken = XOR/Base64\nheaderName metadata\nPOST token 값으로 미사용",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-raw-cookie",
"type": "rectangle",
"x": 399.5,
"y": 242.0,
"width": 181.0,
"height": 105.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": 754250876,
"version": 1,
"versionNonce": 800528172,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-raw-cookie",
"type": "text",
"x": 409.5,
"y": 252.0,
"width": 161.0,
"height": 85.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": 363576130,
"version": 1,
"versionNonce": 808943536,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Browser cookie · raw\nXSRF-TOKEN\nJavaScript-readable\n실제 header data source",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Browser cookie · raw\nXSRF-TOKEN\nJavaScript-readable\n실제 header data source",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-post-assembler",
"type": "rectangle",
"x": 760.0,
"y": 151.0,
"width": 230.0,
"height": 105.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": 1624016171,
"version": 1,
"versionNonce": 1299949724,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-post-assembler",
"type": "text",
"x": 770.0,
"y": 161.0,
"width": 210.0,
"height": 85.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": 1469453706,
"version": 1,
"versionNonce": 539214682,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "SPA POST 조립\nCookie 자동 첨부\ndocument.cookie raw → header\nJSON headerName만 사용",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "SPA POST 조립\nCookie 자동 첨부\ndocument.cookie raw → header\nJSON headerName만 사용",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-csrf-filter",
"type": "rectangle",
"x": 1150.0,
"y": 151.0,
"width": 216.0,
"height": 105.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": 1449308597,
"version": 1,
"versionNonce": 456209351,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-csrf-filter",
"type": "text",
"x": 1160.0,
"y": 161.0,
"width": 196.0,
"height": 85.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": 399971796,
"version": 1,
"versionNonce": 1957873210,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Spring CSRF filter\nraw cookie = raw header 비교\n일치 → controller\n부재·불일치 → 403",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Spring CSRF filter\nraw cookie = raw header 비교\n일치 → controller\n부재·불일치 → 403",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -0,0 +1,32 @@
{
"harness_version": "0.2.0",
"spec_id": "ap3-csrf-boundary",
"spec_version": "1.1",
"spec_sha256": "fe7eed6a9b09c9ca13e32c04d27d1b245676e405bd3f432e363794918772e2b9",
"source_context": {
"document": "document.md",
"document_sha256": "df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371",
"anchor": {
"kind": "marker",
"value": "ap3-csrf-boundary",
"line": 858
}
},
"outputs": [
"ap3-csrf-boundary.svg",
"ap3-csrf-boundary.drawio",
"ap3-csrf-boundary.mmd",
"ap3-csrf-boundary.d2",
"ap3-csrf-boundary.dot",
"ap3-csrf-boundary.excalidraw",
"ap3-csrf-boundary.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "component-flow",
"reference_ids": [
"payment-event-flow"
],
"diagram_only": true
}
@@ -0,0 +1,13 @@
%% AP3의 masked CSRF 응답과 raw POST credential
%% question: AP3에서 CSRF 응답의 masked token과 raw cookie 중 무엇이 실제 POST header의 data source가 되는가?
flowchart LR
n0["BFF · /bff/csrf"]
n1["Browser cookie · raw"]
n2["JSON body · masked"]
n3["SPA POST 조립"]
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
@@ -0,0 +1,104 @@
<?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>

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,25 @@
# AP4 proxy session에서 trusted identity JSON까지
## Alternative text
브라우저, Nginx, oauth2-proxy, Spring upstream 사이에서 AP4_SESSION 검증, identity header 덮어쓰기, internal token 검증과 JSON 응답이 이어지는 순서도.
## Long 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가 브라우저에 전달한다.
## Elements and evidence
- **브라우저** (participant): AP4_SESSION cookie와 함께 edge endpoint를 호출하는 외부 client. Evidence: L992L1007.
- **Nginx edge** (participant): Internal auth subrequest를 만들고 인증 결과를 allowlisted upstream header와 internal token으로 재조립한다. Evidence: L914L939, L1009L1028.
- **oauth2-proxy** (participant): AP4_SESSION을 검증하고 인증된 user, email과 필요 시 갱신 cookie를 auth response로 돌려준다. Evidence: L980L990, L1009L1015.
- **Spring upstream** (participant): Identity header와 internal token을 함께 검증해 allowlisted identity JSON을 반환한다. Evidence: L1017L1026, L1030L1063.
## Relationships
- **브라우저 → Nginx edge:** GET /api/edge + AP4_SESSION. Evidence: L992L999.
- **Nginx edge → oauth2-proxy:** internal /oauth2/auth subrequest. Evidence: L922L939, L1009L1009.
- **oauth2-proxy → Nginx edge:** authenticated user + email. Evidence: L1009L1015.
- **Nginx edge → Spring upstream:** GET /edge/me · trusted headers + internal token. Evidence: L1017L1026.
- **Spring upstream → Nginx edge:** trusted identity JSON. Evidence: L1030L1048.
- **Nginx edge → 브라우저:** pattern + user + email + identityHeader. Evidence: L1039L1048, L1065L1076.
@@ -0,0 +1,21 @@
# AP4 proxy session에서 trusted identity JSON까지
# Question: AP4에서 AP4_SESSION은 어떤 순서로 검증되고 allowlisted identity header와 internal token을 거쳐 JSON이 되는가?
direction: right
n0: "브라우저" {
shape: rectangle
}
n1: "Nginx edge" {
shape: rectangle
}
n2: "oauth2-proxy" {
shape: rectangle
}
n3: "Spring upstream" {
shape: rectangle
}
n0 -> n1: "GET /api/edge + AP4_SESSION"
n1 -> n2: "internal /oauth2/auth subrequest"
n2 -> n1: "authenticated user + email"
n1 -> n3: "GET /edge/me · trusted headers + internal token"
n3 -> n1: "trusted identity JSON"
n1 -> n0: "pattern + user + email + identityHeader"
@@ -0,0 +1,15 @@
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="브라우저", shape=box, style="rounded,filled"];
n1 [label="Nginx edge", shape=box, style="rounded,filled"];
n2 [label="oauth2-proxy", shape=box, style="rounded,filled"];
n3 [label="Spring upstream", shape=box, style="rounded,filled"];
n0 -> n1 [label="GET /api/edge + AP4_SESSION", style=solid];
n1 -> n2 [label="internal /oauth2/auth subrequest", style=solid];
n2 -> n1 [label="authenticated user + email", style=solid];
n1 -> n3 [label="GET /edge/me · trusted headers + internal token", style=solid];
n3 -> n1 [label="trusted identity JSON", style=solid];
n1 -> n0 [label="pattern + user + email + identityHeader", style=solid];
}
@@ -0,0 +1,53 @@
<?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-forward-auth-flow" name="AP4 proxy session에서 trusted identity JSON까지">
<mxGraphModel dx="870" dy="542" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="870" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="n_browser" value="브라우저" tooltip="AP4_SESSION cookie와 함께 edge endpoint를 호출하는 외부 client. | Evidence: L992-L1007" 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="35.0" width="150.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="n_nginx" value="Nginx edge" tooltip="Internal auth subrequest를 만들고 인증 결과를 allowlisted upstream header와 internal token으로 재조립한다. | Evidence: L914-L939, 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="255.0" y="35.0" width="150.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="n_oauth2-proxy" value="oauth2-proxy" tooltip="AP4_SESSION을 검증하고 인증된 user, email과 필요 시 갱신 cookie를 auth response로 돌려준다. | Evidence: L980-L990, L1009-L1015" 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="465.0" y="35.0" width="150.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="n_spring-upstream" value="Spring upstream" tooltip="Identity header와 internal token을 함께 검증해 allowlisted identity JSON을 반환한다. | Evidence: L1017-L1026, L1030-L1063" 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="675.0" y="35.0" width="150.0" height="64.0" as="geometry"/>
</mxCell>
<mxCell id="e_edge-request" 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">
<mxGeometry relative="1" as="geometry">
<mxPoint x="225.0" y="128.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_auth-subrequest" 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">
<mxGeometry relative="1" as="geometry">
<mxPoint x="435.0" y="190.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_auth-result" value="authenticated user + email" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_oauth2-proxy" target="n_nginx">
<mxGeometry relative="1" as="geometry">
<mxPoint x="435.0" y="252.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_upstream-request" value="GET /edge/me · trusted headers + 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">
<mxGeometry relative="1" as="geometry">
<mxPoint x="540.0" y="314.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_identity-map" value="trusted identity JSON" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_spring-upstream" target="n_nginx">
<mxGeometry relative="1" as="geometry">
<mxPoint x="540.0" y="376.0" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_browser-response" value="pattern + user + email + identityHeader" 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_browser">
<mxGeometry relative="1" as="geometry">
<mxPoint x="225.0" y="438.0" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,835 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "edge-edge-request",
"type": "arrow",
"x": 120.0,
"y": 140.0,
"width": 210.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 484293938,
"version": 1,
"versionNonce": 138566560,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
210.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-browser",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-nginx",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-edge-request",
"type": "text",
"x": 117.0,
"y": 116.0,
"width": 216,
"height": 24,
"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": 221805120,
"version": 1,
"versionNonce": 1825067634,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "GET /api/edge + AP4_SESSION",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "GET /api/edge + AP4_SESSION",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-auth-subrequest",
"type": "arrow",
"x": 330.0,
"y": 202.0,
"width": 210.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1667458436,
"version": 1,
"versionNonce": 1627457069,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
210.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-nginx",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-oauth2-proxy",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-auth-subrequest",
"type": "text",
"x": 307.0,
"y": 178.0,
"width": 256,
"height": 24,
"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": 667482483,
"version": 1,
"versionNonce": 461894769,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "internal /oauth2/auth subrequest",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "internal /oauth2/auth subrequest",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-auth-result",
"type": "arrow",
"x": 330.0,
"y": 264.0,
"width": 210.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1075862510,
"version": 1,
"versionNonce": 1988805447,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
210.0,
0.0
],
[
0.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-oauth2-proxy",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-nginx",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-auth-result",
"type": "text",
"x": 331.0,
"y": 240.0,
"width": 208,
"height": 24,
"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": 863949098,
"version": 1,
"versionNonce": 1824385289,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "authenticated user + email",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "authenticated user + email",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-upstream-request",
"type": "arrow",
"x": 330.0,
"y": 326.0,
"width": 420.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 217844129,
"version": 1,
"versionNonce": 1633365074,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
420.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-nginx",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-spring-upstream",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-upstream-request",
"type": "text",
"x": 352.0,
"y": 302.0,
"width": 376,
"height": 24,
"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": 1518859962,
"version": 1,
"versionNonce": 257106806,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "GET /edge/me · trusted headers + internal token",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "GET /edge/me · trusted headers + internal token",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-identity-map",
"type": "arrow",
"x": 330.0,
"y": 388.0,
"width": 420.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 7171349,
"version": 1,
"versionNonce": 969751315,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
420.0,
0.0
],
[
0.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-spring-upstream",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-nginx",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-identity-map",
"type": "text",
"x": 456.0,
"y": 364.0,
"width": 168,
"height": 24,
"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": 509639853,
"version": 1,
"versionNonce": 489035676,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "trusted identity JSON",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "trusted identity JSON",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-browser-response",
"type": "arrow",
"x": 120.0,
"y": 450.0,
"width": 210.0,
"height": 0.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1388383895,
"version": 1,
"versionNonce": 1053957646,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
210.0,
0.0
],
[
0.0,
0.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-nginx",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-browser",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-browser-response",
"type": "text",
"x": 69.0,
"y": 426.0,
"width": 312,
"height": 24,
"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": 1885175380,
"version": 1,
"versionNonce": 735131413,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "pattern + user + email + identityHeader",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "pattern + user + email + identityHeader",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-browser",
"type": "rectangle",
"x": 45.0,
"y": 35.0,
"width": 150.0,
"height": 64.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": 1549664096,
"version": 1,
"versionNonce": 397227867,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-browser",
"type": "text",
"x": 55.0,
"y": 45.0,
"width": 130.0,
"height": 44.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": 225442297,
"version": 1,
"versionNonce": 1441680053,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "브라우저",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "브라우저",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-nginx",
"type": "rectangle",
"x": 255.0,
"y": 35.0,
"width": 150.0,
"height": 64.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": 721013300,
"version": 1,
"versionNonce": 1520646945,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-nginx",
"type": "text",
"x": 265.0,
"y": 45.0,
"width": 130.0,
"height": 44.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": 117243520,
"version": 1,
"versionNonce": 936748041,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Nginx edge",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Nginx edge",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-oauth2-proxy",
"type": "rectangle",
"x": 465.0,
"y": 35.0,
"width": 150.0,
"height": 64.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": 1926678177,
"version": 1,
"versionNonce": 124865767,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-oauth2-proxy",
"type": "text",
"x": 475.0,
"y": 45.0,
"width": 130.0,
"height": 44.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": 211503580,
"version": 1,
"versionNonce": 929312460,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "oauth2-proxy",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "oauth2-proxy",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-spring-upstream",
"type": "rectangle",
"x": 675.0,
"y": 35.0,
"width": 150.0,
"height": 64.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": 685.0,
"y": 45.0,
"width": 130.0,
"height": 44.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",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Spring upstream",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -0,0 +1,32 @@
{
"harness_version": "0.2.0",
"spec_id": "ap4-edge-forward-auth-flow",
"spec_version": "1.1",
"spec_sha256": "18ce47da22142be0655461f3677df193562cb037f936b1a8f3d846665187f300",
"source_context": {
"document": "document.md",
"document_sha256": "df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371",
"anchor": {
"kind": "marker",
"value": "ap4-edge-forward-auth-flow",
"line": 1108
}
},
"outputs": [
"ap4-edge-forward-auth-flow.svg",
"ap4-edge-forward-auth-flow.drawio",
"ap4-edge-forward-auth-flow.mmd",
"ap4-edge-forward-auth-flow.d2",
"ap4-edge-forward-auth-flow.dot",
"ap4-edge-forward-auth-flow.excalidraw",
"ap4-edge-forward-auth-flow.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "sequence",
"reference_ids": [
"payment-approval-sequence"
],
"diagram_only": true
}
@@ -0,0 +1,13 @@
%% AP4 proxy session에서 trusted identity JSON까지
%% question: AP4에서 AP4_SESSION은 어떤 순서로 검증되고 allowlisted identity header와 internal token을 거쳐 JSON이 되는가?
sequenceDiagram
participant n0 as 브라우저
participant n1 as Nginx edge
participant n2 as oauth2-proxy
participant n3 as Spring upstream
n0->>n1: GET /api/edge + AP4_SESSION
n1->>n2: internal /oauth2/auth subrequest
n2->>n1: authenticated user + email
n1->>n3: GET /edge/me · trusted headers + internal token
n3->>n1: trusted identity JSON
n1->>n0: pattern + user + email + identityHeader
@@ -0,0 +1,83 @@
<?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>

After

Width:  |  Height:  |  Size: 7.5 KiB

@@ -0,0 +1,24 @@
# AP4는 공개 edge에서 session을 정제된 upstream identity로 바꾼다
## Alternative text
외부 브라우저 zone과 Nginx, oauth2-proxy, Spring upstream이 있는 AP4 deployment path를 나눈 edge trust 아키텍처.
## Long 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를 직접 입력으로 받지 않는다.
## 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.
## 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.
@@ -0,0 +1,22 @@
# AP4는 공개 edge에서 session을 정제된 upstream identity로 바꾼다
# Question: AP4에서 외부 session 요청은 어떤 공개·비공개 경계를 지나 trusted identity header와 internal token이 되는가?
direction: right
g0: "External browser" {
n0: "Browser" {
shape: person
}
}
g1: "AP4 deployment path" {
n1: "Nginx edge" {
shape: rectangle
}
n2: "Spring upstream" {
shape: rectangle
}
n3: "oauth2-proxy" {
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"
@@ -0,0 +1,22 @@
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];
subgraph cluster_0 {
label="External browser";
style="rounded,dashed";
color="#66788a";
n0 [label="Browser", shape=box, style="rounded,dashed,filled"];
}
subgraph cluster_1 {
label="AP4 deployment path";
style="rounded,dashed";
color="#66788a";
n1 [label="Nginx edge", shape=box, style="rounded,filled"];
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];
}
@@ -0,0 +1,44 @@
<?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">
<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"/>
</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"/>
</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>
<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>
<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>
<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>
<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">
<mxGeometry relative="1" as="geometry">
<mxPoint x="454.0" y="138.0" 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">
<mxGeometry relative="1" as="geometry">
<mxPoint x="714.0" y="220.2" 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">
<mxGeometry relative="1" as="geometry">
<mxPoint x="787.0" y="31.0" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>
@@ -0,0 +1,732 @@
{
"type": "excalidraw",
"version": 2,
"source": "techviz-harness",
"elements": [
{
"id": "group-external-client-zone",
"type": "rectangle",
"x": 45.0,
"y": 49.0,
"width": 250.0,
"height": 156.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#f8f9fa",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "dashed",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 636102247,
"version": 1,
"versionNonce": 1680055419,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "group-label-external-client-zone",
"type": "text",
"x": 61.0,
"y": 55.0,
"width": 144,
"height": 24,
"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": 1653486962,
"version": 1,
"versionNonce": 1053913492,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 14,
"fontFamily": 5,
"text": "External browser",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "External browser",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "group-ap4-deployment-zone",
"type": "rectangle",
"x": 565.0,
"y": 49.0,
"width": 470.0,
"height": 322.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#f8f9fa",
"fillStyle": "solid",
"strokeWidth": 1,
"strokeStyle": "dashed",
"roughness": 0,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1563354189,
"version": 1,
"versionNonce": 1573258450,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "group-label-ap4-deployment-zone",
"type": "text",
"x": 581.0,
"y": 55.0,
"width": 171,
"height": 24,
"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": 1384151847,
"version": 1,
"versionNonce": 352546226,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 14,
"fontFamily": 5,
"text": "AP4 deployment path",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "AP4 deployment path",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-browser-to-nginx",
"type": "arrow",
"x": 265.0,
"y": 137.0,
"width": 330.0,
"height": 2.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 1516194596,
"version": 1,
"versionNonce": 422881189,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
0.0
],
[
165.0,
0.0
],
[
165.0,
2.0
],
[
330.0,
2.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-browser",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-nginx",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-browser-to-nginx",
"type": "text",
"x": 346.0,
"y": 126.0,
"width": 216,
"height": 24,
"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": 219333600,
"version": 1,
"versionNonce": 1048444268,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "GET /api/edge · AP4_SESSION",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "GET /api/edge · AP4_SESSION",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "edge-nginx-to-oauth2-proxy",
"type": "arrow",
"x": 595.0,
"y": 148.0,
"width": 190.0,
"height": 144.5,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 981464051,
"version": 1,
"versionNonce": 1566533449,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
190.0,
0.0
],
[
95.0,
0.0
],
[
95.0,
144.5
],
[
0.0,
144.5
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-nginx",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-oauth2-proxy",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-nginx-to-oauth2-proxy",
"type": "text",
"x": 586.0,
"y": 208.25,
"width": 256,
"height": 24,
"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": 707244903,
"version": 1,
"versionNonce": 1419729975,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "internal /oauth2/auth subrequest",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "internal /oauth2/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,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": null,
"seed": 455545982,
"version": 1,
"versionNonce": 649629810,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"points": [
[
0.0,
71.0
],
[
30.0,
71.0
],
[
30.0,
0.0
],
[
0.0,
0.0
],
[
0.0,
97.0
],
[
30.0,
97.0
]
],
"lastCommittedPoint": null,
"startBinding": {
"elementId": "node-nginx",
"focus": 0,
"gap": 4
},
"endBinding": {
"elementId": "node-spring-upstream",
"focus": 0,
"gap": 4
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": true
},
{
"id": "edge-label-nginx-to-upstream",
"type": "text",
"x": 639.0,
"y": 19.0,
"width": 296,
"height": 24,
"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": 1978664924,
"version": 1,
"versionNonce": 184861660,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 13,
"fontFamily": 5,
"text": "allowlisted identity + internal token",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "allowlisted identity + internal token",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-browser",
"type": "rectangle",
"x": 75.0,
"y": 95.0,
"width": 190.0,
"height": 84.0,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "#ffffff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "dashed",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": null,
"roundness": {
"type": 3
},
"seed": 1549664096,
"version": 1,
"versionNonce": 397227867,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-browser",
"type": "text",
"x": 85.0,
"y": 105.0,
"width": 170.0,
"height": 64.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": 225442297,
"version": 1,
"versionNonce": 1441680053,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Browser\nHttpOnly AP4_SESSION",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "Browser\nHttpOnly AP4_SESSION",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-nginx",
"type": "rectangle",
"x": 595.0,
"y": 95.0,
"width": 190.0,
"height": 88.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": 721013300,
"version": 1,
"versionNonce": 1520646945,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-nginx",
"type": "text",
"x": 605.0,
"y": 105.0,
"width": 170.0,
"height": 68.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": 117243520,
"version": 1,
"versionNonce": 936748041,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "Nginx 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",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "node-oauth2-proxy",
"type": "rectangle",
"x": 595.0,
"y": 240.0,
"width": 190.0,
"height": 105.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": 1926678177,
"version": 1,
"versionNonce": 124865767,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false
},
{
"id": "node-label-oauth2-proxy",
"type": "text",
"x": 605.0,
"y": 250.0,
"width": 170.0,
"height": 85.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": 211503580,
"version": 1,
"versionNonce": 929312460,
"isDeleted": false,
"boundElements": [],
"updated": 0,
"link": null,
"locked": false,
"fontSize": 15,
"fontFamily": 5,
"text": "oauth2-proxy\nedge-proxy client\nminimal cookie session\nreturns user · email",
"textAlign": "center",
"verticalAlign": "middle",
"containerId": null,
"originalText": "oauth2-proxy\nedge-proxy client\nminimal cookie session\nreturns user · email",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 10,
"viewBackgroundColor": "#ffffff",
"currentItemFontFamily": 5
},
"files": {}
}
@@ -0,0 +1,32 @@
{
"harness_version": "0.2.0",
"spec_id": "ap4-edge-trust-architecture",
"spec_version": "1.1",
"spec_sha256": "c236660298ed5ff0bc3b8ce960fccdffefe7d45047f379c4067bfd2934d0e0b9",
"source_context": {
"document": "document.md",
"document_sha256": "df4d1a604c74e756672b5b40510abfedb8c67b39af280a5f51985ea9972f5371",
"anchor": {
"kind": "marker",
"value": "ap4-edge-trust-architecture",
"line": 182
}
},
"outputs": [
"ap4-edge-trust-architecture.svg",
"ap4-edge-trust-architecture.drawio",
"ap4-edge-trust-architecture.mmd",
"ap4-edge-trust-architecture.d2",
"ap4-edge-trust-architecture.dot",
"ap4-edge-trust-architecture.excalidraw",
"ap4-edge-trust-architecture.alt.md"
],
"lint_issue_count": 0,
"assumption_count": 0,
"assumptions_allowed": false,
"composition_profile": "two-zone-pipeline",
"reference_ids": [
"localization-pipeline"
],
"diagram_only": true
}
@@ -0,0 +1,16 @@
%% AP4는 공개 edge에서 session을 정제된 upstream identity로 바꾼다
%% question: AP4에서 외부 session 요청은 어떤 공개·비공개 경계를 지나 trusted identity header와 internal token이 되는가?
flowchart LR
subgraph g_external_client_zone["External browser"]
n0(["Browser"])
end
subgraph g_ap4_deployment_zone["AP4 deployment path"]
n1["Nginx edge"]
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
classDef external stroke-dasharray: 6 4
class n0 external
@@ -0,0 +1,96 @@
<?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">
<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>
<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="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>
<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>
<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>
</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>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.1 KiB

@@ -0,0 +1,16 @@
# 패턴 이동은 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.
@@ -0,0 +1,15 @@
# 패턴 이동은 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
}
@@ -0,0 +1,9 @@
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"];
}
@@ -0,0 +1,23 @@
<?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>
@@ -0,0 +1,289 @@
{
"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": {}
}
@@ -0,0 +1,32 @@
{
"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
}
@@ -0,0 +1,7 @@
%% 패턴 이동은 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"]
@@ -0,0 +1,93 @@
<?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>

After

Width:  |  Height:  |  Size: 7.5 KiB

@@ -0,0 +1,16 @@
# 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.
@@ -0,0 +1,15 @@
# 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
}
@@ -0,0 +1,9 @@
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"];
}
@@ -0,0 +1,23 @@
<?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>
@@ -0,0 +1,289 @@
{
"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": {}
}
@@ -0,0 +1,32 @@
{
"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
}
@@ -0,0 +1,7 @@
%% Credential은 사라지지 않고 owner와 형태가 바뀐다
%% question: 네 패턴은 애플리케이션 credential을 어느 경계에 보관하며 브라우저 상태와 운영 근거에는 무엇이 남는가?
flowchart LR
n0["AP1 · browser memory"]
n1["AP2 · mediator custody"]
n2["AP3 · BFF/session"]
n3["AP4 · proxy session"]
@@ -0,0 +1,89 @@
<?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>

After

Width:  |  Height:  |  Size: 7.4 KiB

@@ -0,0 +1,16 @@
# 로그인 후 요청에서 달라지는 네 인증 경계
## 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.
@@ -0,0 +1,15 @@
# 로그인 후 요청에서 달라지는 네 인증 경계
# 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
}
@@ -0,0 +1,9 @@
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"];
}
@@ -0,0 +1,23 @@
<?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>
@@ -0,0 +1,289 @@
{
"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": {}
}
@@ -0,0 +1,32 @@
{
"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
}
@@ -0,0 +1,7 @@
%% 로그인 후 요청에서 달라지는 네 인증 경계
%% question: AP1부터 AP4까지 브라우저 입력, credential 변환, 보호 자원 입력과 최종 출력은 어떻게 다른가?
flowchart LR
n0["AP1 · SPA direct"]
n1["AP2 · token mediator"]
n2["AP3 · BFF"]
n3["AP4 · edge forward-auth"]
@@ -0,0 +1,90 @@
<?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>

After

Width:  |  Height:  |  Size: 7.1 KiB

@@ -0,0 +1,34 @@
# 로그인 credential owner와 API caller는 같은 축이 아니다
## Alternative text
로그인 구간과 애플리케이션 요청 구간을 나누어 AP2 mediator·브라우저, AP3 BFF, AP4 oauth2-proxy·Nginx의 책임 배치를 비교한 다이어그램.
## Long 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 조립을 맡는다.
## Elements and evidence
- **Boundary: 로그인 구간** (lifecycle): Authorization request, callback, code 교환과 로그인 상태 생성이 일어나는 구간. Evidence: L37L39.
- **Boundary: 애플리케이션 요청 구간** (lifecycle): 브라우저 입력부터 credential 변환, 보호 자원 검증과 최종 응답까지의 구간. Evidence: L37L40.
- **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.
## 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.
@@ -0,0 +1,40 @@
# 로그인 credential owner와 API caller는 같은 축이 아니다
# Question: 로그인 구간의 credential owner와 애플리케이션 요청 구간의 caller는 AP2·AP3·AP4에서 어디서 같고 갈라지는가?
direction: right
g0: "로그인 구간" {
n0: "Keycloak" {
shape: rectangle
}
n1: "AP2 mediator" {
shape: rectangle
}
n2: "AP3 BFF · 로그인" {
shape: rectangle
}
n3: "AP4 oauth2-proxy" {
shape: rectangle
}
}
g1: "애플리케이션 요청 구간" {
n4: "AP2 브라우저" {
shape: person
}
n5: "AP3 BFF · API" {
shape: rectangle
}
n6: "AP4 Nginx" {
shape: rectangle
}
n7: "보호 자원" {
shape: rectangle
}
}
g0.n0 -> g0.n1: "code → token"
g0.n0 -> g0.n2: "code → token"
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"
@@ -0,0 +1,32 @@
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];
subgraph cluster_0 {
label="로그인 구간";
style="rounded,dashed";
color="#66788a";
n0 [label="Keycloak", shape=box, style="rounded,filled"];
n1 [label="AP2 mediator", shape=box, style="rounded,filled"];
n2 [label="AP3 BFF · 로그인", shape=box, style="rounded,filled"];
n3 [label="AP4 oauth2-proxy", shape=box, style="rounded,filled"];
}
subgraph cluster_1 {
label="애플리케이션 요청 구간";
style="rounded,dashed";
color="#66788a";
n4 [label="AP2 브라우저", shape=box, style="rounded,dashed,filled"];
n5 [label="AP3 BFF · API", shape=box, style="rounded,filled"];
n6 [label="AP4 Nginx", shape=box, style="rounded,filled"];
n7 [label="보호 자원", shape=box, style="rounded,filled"];
}
n0 -> n1 [label="code → token", style=solid];
n0 -> n2 [label="code → token", style=solid];
n0 -> n3 [label="OIDC code 교환", style=solid];
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];
}
@@ -0,0 +1,86 @@
<?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="login-api-phase-split" name="로그인 credential owner와 API caller는 같은 축이 아니다">
<mxGraphModel dx="1235" dy="539" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1235" pageHeight="1169" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="g_login-phase" value="로그인 구간" 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="40.0" y="41.5" width="520.0" height="446.0" as="geometry"/>
</mxCell>
<mxCell id="g_application-request-phase" value="애플리케이션 요청 구간" 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="660.0" y="35.0" width="530.0" height="459.0" as="geometry"/>
</mxCell>
<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">
<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">
<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">
<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">
<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">
<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">
<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>
<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">
<mxGeometry relative="1" as="geometry">
<mxPoint x="951.5" y="189.8" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_ap2-owner-split" value="owner 분리" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_ap2-mediator-login" target="n_ap2-browser-caller">
<mxGeometry relative="1" as="geometry">
<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">
<mxGeometry relative="1" as="geometry">
<mxPoint x="951.5" y="273.5" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_ap3-same-owner" value="동일 BFF" 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-login" target="n_ap3-bff-caller">
<mxGeometry relative="1" as="geometry">
<mxPoint x="636.5" y="269.2" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_ap4-owner-handoff" value="auth 결과" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_ap4-oauth2-proxy" target="n_ap4-nginx-caller">
<mxGeometry relative="1" as="geometry">
<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">
<mxGeometry relative="1" as="geometry">
<mxPoint x="954.0" y="358.2" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_keycloak-to-ap2" value="code → token" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_keycloak" target="n_ap2-mediator-login">
<mxGeometry relative="1" as="geometry">
<mxPoint x="324.0" y="189.8" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_keycloak-to-ap3" value="code → token" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_keycloak" target="n_ap3-bff-login">
<mxGeometry relative="1" as="geometry">
<mxPoint x="324.0" y="270.2" as="offset"/>
</mxGeometry>
</mxCell>
<mxCell id="e_keycloak-to-ap4" value="OIDC code 교환" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;strokeWidth=2;endArrow=block;endFill=1;" edge="1" parent="1" source="n_keycloak" target="n_ap4-oauth2-proxy">
<mxGeometry relative="1" as="geometry">
<mxPoint x="324.0" y="355.0" as="offset"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

Some files were not shown because too many files have changed in this diff Show More