diff --git a/.playwright-mcp/console-2026-09-04T04-19-58-373Z.log b/.playwright-mcp/console-2026-09-04T04-19-58-373Z.log index cdbb7c6..30bb419 100644 --- a/.playwright-mcp/console-2026-09-04T04-19-58-373Z.log +++ b/.playwright-mcp/console-2026-09-04T04-19-58-373Z.log @@ -82,3 +82,62 @@ [ 776007ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 [ 792298ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 [ 803509ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 822917ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 827374ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 847278ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 867460ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 875338ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 886296ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 900938ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 912511ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 917067ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 924405ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 930435ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 942211ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 953146ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 957153ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 969140ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 984088ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1003544ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1014908ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1020851ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1034668ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1040406ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1056887ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1062828ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1073375ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1085052ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1102031ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1110039ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1129341ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1132300ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1133670ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1142705ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1160906ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1172400ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1190482ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1196673ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1213666ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1225136ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1228310ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1242751ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1243874ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1256126ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1274444ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1288526ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1307058ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1320986ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1325134ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1335974ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1344832ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1354780ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1364165ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1383346ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1399019ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1400207ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1413875ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1423809ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1427502ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1430773ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1436408ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 +[ 1447464ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362 diff --git a/.playwright-mcp/console-2026-09-04T04-46-50-423Z.log b/.playwright-mcp/console-2026-09-04T04-46-50-423Z.log new file mode 100644 index 0000000..9c1c2aa --- /dev/null +++ b/.playwright-mcp/console-2026-09-04T04-46-50-423Z.log @@ -0,0 +1 @@ +[ 207ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ https://app1.hyeonworks.com/favicon.ico:0 diff --git a/.playwright-mcp/page-2026-09-04T04-44-07-075Z.yml b/.playwright-mcp/page-2026-09-04T04-44-07-075Z.yml new file mode 100644 index 0000000..9abba56 --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T04-44-07-075Z.yml @@ -0,0 +1,7 @@ +- main [ref=f21e2]: + - heading "AP3 · Backend-for-Frontend" [level=1] [ref=f21e3] + - paragraph [ref=f21e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다. + - button "Keycloak 로그인" [ref=f21e5] [cursor=pointer] + - button "token 경계 확인" [ref=f21e6] [cursor=pointer] + - button "BFF 경유 API 호출" [ref=f21e7] [cursor=pointer] + - button "CSRF token으로 상태 변경" [ref=f21e8] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-04T04-44-20-764Z.yml b/.playwright-mcp/page-2026-09-04T04-44-20-764Z.yml new file mode 100644 index 0000000..89e2920 --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T04-44-20-764Z.yml @@ -0,0 +1,16 @@ +- generic [ref=f22e3]: + - banner [ref=f22e4]: + - generic [ref=f22e5]: keycloak-patterns + - main [ref=f22e6]: + - heading "Sign in to your account" [level=1] [ref=f22e8] + - generic [ref=f22e12]: + - generic [ref=f22e13]: + - generic [ref=f22e14]: Username or email + - textbox "Username or email" [active] [ref=f22e17] + - generic [ref=f22e18]: + - generic [ref=f22e19]: Password + - generic [ref=f22e21]: + - textbox "Password" [ref=f22e24] + - button "Show password" [ref=f22e26] [cursor=pointer]: + - generic [aria-hidden] [ref=f22e27]:  + - button "Sign In" [ref=f22e30] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-04T04-44-32-327Z.yml b/.playwright-mcp/page-2026-09-04T04-44-32-327Z.yml new file mode 100644 index 0000000..e59ea0c --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T04-44-32-327Z.yml @@ -0,0 +1,20 @@ +- generic [ref=f23e3]: + - banner [ref=f23e4]: + - generic [ref=f23e5]: keycloak-patterns + - main [ref=f23e6]: + - heading "Update Account Information" [level=1] [ref=f23e8] + - generic [ref=f23e9]: + - generic [ref=f23e10]: "* Required fields" + - generic [ref=f23e13]: + - generic [ref=f23e14]: + - generic [ref=f23e15]: Email * + - textbox "Email" [ref=f23e19]: labuser@example.com + - generic [ref=f23e20]: + - generic [ref=f23e21]: First name * + - textbox "First name" [invalid] [ref=f23e25] + - generic [ref=f23e26]: Please specify this field. + - generic [ref=f23e31]: + - generic [ref=f23e32]: Last name * + - textbox "Last name" [invalid] [ref=f23e36] + - generic [ref=f23e37]: Please specify this field. + - button "Submit" [ref=f23e44] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-04T04-44-52-039Z.yml b/.playwright-mcp/page-2026-09-04T04-44-52-039Z.yml new file mode 100644 index 0000000..ded23b4 --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T04-44-52-039Z.yml @@ -0,0 +1,20 @@ +- generic [ref=f23e3]: + - banner [ref=f23e4]: + - generic [ref=f23e5]: keycloak-patterns + - main [ref=f23e6]: + - heading "Update Account Information" [level=1] [ref=f23e8] + - generic [ref=f23e9]: + - generic [ref=f23e10]: "* Required fields" + - generic [ref=f23e13]: + - generic [ref=f23e14]: + - generic [ref=f23e15]: Email * + - textbox "Email" [ref=f23e19]: labuser@example.com + - generic [ref=f23e20]: + - generic [ref=f23e21]: First name * + - textbox "First name" [invalid] [ref=f23e25]: Lab + - generic [ref=f23e26]: Please specify this field. + - generic [ref=f23e31]: + - generic [ref=f23e32]: Last name * + - textbox "Last name" [active] [invalid] [ref=f23e36]: User + - generic [ref=f23e37]: Please specify this field. + - button "Submit" [ref=f23e44] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-04T04-45-23-537Z.yml b/.playwright-mcp/page-2026-09-04T04-45-23-537Z.yml new file mode 100644 index 0000000..78d1698 --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T04-45-23-537Z.yml @@ -0,0 +1,7 @@ +- main [ref=f24e2]: + - heading "AP3 · Backend-for-Frontend" [level=1] [ref=f24e3] + - paragraph [ref=f24e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다. + - button "Keycloak 로그인" [ref=f24e5] [cursor=pointer] + - button "token 경계 확인" [ref=f24e6] [cursor=pointer] + - button "BFF 경유 API 호출" [ref=f24e7] [cursor=pointer] + - button "CSRF token으로 상태 변경" [ref=f24e8] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-04T04-45-28-992Z.yml b/.playwright-mcp/page-2026-09-04T04-45-28-992Z.yml new file mode 100644 index 0000000..a75f3d6 --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T04-45-28-992Z.yml @@ -0,0 +1,16 @@ +- generic [ref=f25e3]: + - banner [ref=f25e4]: + - generic [ref=f25e5]: keycloak-patterns + - main [ref=f25e6]: + - heading "Sign in to your account" [level=1] [ref=f25e8] + - generic [ref=f25e12]: + - generic [ref=f25e13]: + - generic [ref=f25e14]: Username or email + - textbox "Username or email" [active] [ref=f25e17] + - generic [ref=f25e18]: + - generic [ref=f25e19]: Password + - generic [ref=f25e21]: + - textbox "Password" [ref=f25e24] + - button "Show password" [ref=f25e26] [cursor=pointer]: + - generic [aria-hidden] [ref=f25e27]:  + - button "Sign In" [ref=f25e30] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-04T04-45-37-881Z.yml b/.playwright-mcp/page-2026-09-04T04-45-37-881Z.yml new file mode 100644 index 0000000..4f833b8 --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T04-45-37-881Z.yml @@ -0,0 +1,9 @@ +- generic [ref=f26e2]: + - heading "Login with OAuth 2.0" [level=2] [ref=f26e3] + - alert [ref=f26e4]: Invalid credentials + - table [ref=f26e5]: + - rowgroup [ref=f26e6]: + - row [ref=f26e7]: + - cell [ref=f26e8]: + - link "keycloak" [ref=f26e9] [cursor=pointer]: + - /url: /oauth2/authorization/keycloak \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-04T04-46-14-914Z.yml b/.playwright-mcp/page-2026-09-04T04-46-14-914Z.yml new file mode 100644 index 0000000..0f16324 --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T04-46-14-914Z.yml @@ -0,0 +1,7 @@ +- main [ref=f27e2]: + - heading "AP3 · Backend-for-Frontend" [level=1] [ref=f27e3] + - paragraph [ref=f27e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다. + - button "Keycloak 로그인" [ref=f27e5] [cursor=pointer] + - button "token 경계 확인" [ref=f27e6] [cursor=pointer] + - button "BFF 경유 API 호출" [ref=f27e7] [cursor=pointer] + - button "CSRF token으로 상태 변경" [ref=f27e8] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-04T04-46-19-545Z.yml b/.playwright-mcp/page-2026-09-04T04-46-19-545Z.yml new file mode 100644 index 0000000..0f16324 --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T04-46-19-545Z.yml @@ -0,0 +1,7 @@ +- main [ref=f27e2]: + - heading "AP3 · Backend-for-Frontend" [level=1] [ref=f27e3] + - paragraph [ref=f27e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다. + - button "Keycloak 로그인" [ref=f27e5] [cursor=pointer] + - button "token 경계 확인" [ref=f27e6] [cursor=pointer] + - button "BFF 경유 API 호출" [ref=f27e7] [cursor=pointer] + - button "CSRF token으로 상태 변경" [ref=f27e8] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-04T04-46-29-422Z.yml b/.playwright-mcp/page-2026-09-04T04-46-29-422Z.yml new file mode 100644 index 0000000..0f16324 --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T04-46-29-422Z.yml @@ -0,0 +1,7 @@ +- main [ref=f27e2]: + - heading "AP3 · Backend-for-Frontend" [level=1] [ref=f27e3] + - paragraph [ref=f27e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다. + - button "Keycloak 로그인" [ref=f27e5] [cursor=pointer] + - button "token 경계 확인" [ref=f27e6] [cursor=pointer] + - button "BFF 경유 API 호출" [ref=f27e7] [cursor=pointer] + - button "CSRF token으로 상태 변경" [ref=f27e8] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-04T04-46-50-621Z.yml b/.playwright-mcp/page-2026-09-04T04-46-50-621Z.yml new file mode 100644 index 0000000..a475263 --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T04-46-50-621Z.yml @@ -0,0 +1 @@ +- generic [active] [ref=f28e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":true,\"refreshTokenStoredOnServer\":true,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}" \ No newline at end of file diff --git a/bff/.dockerignore b/bff/.dockerignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/bff/.dockerignore @@ -0,0 +1 @@ +target/ diff --git a/bff/Dockerfile b/bff/Dockerfile new file mode 100644 index 0000000..b3bf5a6 --- /dev/null +++ b/bff/Dockerfile @@ -0,0 +1,14 @@ +FROM maven:3.9.11-eclipse-temurin-21-alpine AS build +WORKDIR /workspace +COPY pom.xml . +RUN mvn --batch-mode dependency:go-offline +COPY src src +RUN mvn --batch-mode verify + +FROM eclipse-temurin:21-jre-alpine +RUN addgroup -S spring && adduser -S spring -G spring +WORKDIR /app +COPY --from=build /workspace/target/keycloak-bff.jar app.jar +USER spring:spring +EXPOSE 8083 +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/bff/pom.xml b/bff/pom.xml new file mode 100644 index 0000000..093b952 --- /dev/null +++ b/bff/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.16 + + + + com.example + keycloak-bff + 0.0.1-SNAPSHOT + keycloak-bff + + + 21 + + + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + + + keycloak-bff + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/bff/src/main/java/com/example/keycloakpattern/bff/BffApplication.java b/bff/src/main/java/com/example/keycloakpattern/bff/BffApplication.java new file mode 100644 index 0000000..c9b6ea8 --- /dev/null +++ b/bff/src/main/java/com/example/keycloakpattern/bff/BffApplication.java @@ -0,0 +1,12 @@ +package com.example.keycloakpattern.bff; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class BffApplication { + + public static void main(String[] args) { + SpringApplication.run(BffApplication.class, args); + } +} diff --git a/bff/src/main/java/com/example/keycloakpattern/bff/BffController.java b/bff/src/main/java/com/example/keycloakpattern/bff/BffController.java new file mode 100644 index 0000000..cf813fc --- /dev/null +++ b/bff/src/main/java/com/example/keycloakpattern/bff/BffController.java @@ -0,0 +1,112 @@ +package com.example.keycloakpattern.bff; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestClient; +import org.springframework.web.server.ResponseStatusException; + +import static org.springframework.http.HttpStatus.UNAUTHORIZED; + +@RestController +public class BffController { + + private final OAuth2AuthorizedClientService authorizedClientService; + private final OAuth2AuthorizedClientManager authorizedClientManager; + private final RestClient resourceApi; + private final AtomicReference theme = new AtomicReference<>("system"); + + public BffController( + OAuth2AuthorizedClientService authorizedClientService, + OAuth2AuthorizedClientManager authorizedClientManager, + RestClient.Builder restClientBuilder, + @Value("${resource-api.base-url}") String resourceApiBaseUrl + ) { + this.authorizedClientService = authorizedClientService; + this.authorizedClientManager = authorizedClientManager; + this.resourceApi = restClientBuilder.baseUrl(resourceApiBaseUrl).build(); + } + + @GetMapping("/bff/token-boundary") + ResponseEntity> tokenBoundary(Authentication authentication) { + OAuth2AuthorizedClient client = authorizedClientService.loadAuthorizedClient( + "keycloak", + authentication.getName() + ); + + Map response = new LinkedHashMap<>(); + response.put("pattern", "AP3-backend-for-frontend"); + response.put("principal", authentication.getName()); + response.put("accessTokenStoredOnServer", client != null + && client.getAccessToken() != null); + response.put("refreshTokenStoredOnServer", client != null + && client.getRefreshToken() != null); + response.put("browserTokenCount", 0); + response.put("csrfProtectionEnabled", true); + + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .header("Pragma", "no-cache") + .body(response); + } + + @GetMapping("/bff/api/me") + ResponseEntity currentUser(Authentication authentication) { + OAuth2AuthorizedClient client = authorizedClient(authentication); + return resourceApi.get() + .uri("/api/me") + .header( + HttpHeaders.AUTHORIZATION, + "Bearer " + client.getAccessToken().getTokenValue() + ) + .retrieve() + .toEntity(Map.class); + } + + @PostMapping("/bff/api/preferences") + Map updatePreference( + Authentication authentication, + @RequestParam(defaultValue = "system") String theme + ) { + this.theme.set(theme); + return Map.of( + "updated", true, + "theme", this.theme.get(), + "principal", authentication.getName() + ); + } + + @GetMapping("/bff/api/preferences") + Map preference() { + return Map.of("theme", theme.get()); + } + + private OAuth2AuthorizedClient authorizedClient(Authentication authentication) { + OAuth2AuthorizeRequest request = OAuth2AuthorizeRequest + .withClientRegistrationId("keycloak") + .principal(authentication) + .build(); + OAuth2AuthorizedClient client = authorizedClientManager.authorize(request); + if (client == null || client.getAccessToken() == null) { + throw new ResponseStatusException( + UNAUTHORIZED, + "No authorized Keycloak client is available" + ); + } + return client; + } +} diff --git a/bff/src/main/java/com/example/keycloakpattern/bff/CsrfController.java b/bff/src/main/java/com/example/keycloakpattern/bff/CsrfController.java new file mode 100644 index 0000000..fa9a859 --- /dev/null +++ b/bff/src/main/java/com/example/keycloakpattern/bff/CsrfController.java @@ -0,0 +1,25 @@ +package com.example.keycloakpattern.bff; + +import java.util.Map; + +import org.springframework.http.CacheControl; +import org.springframework.http.ResponseEntity; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class CsrfController { + + @GetMapping("/bff/csrf") + ResponseEntity> csrf(CsrfToken csrfToken) { + return ResponseEntity.ok() + .cacheControl(CacheControl.noStore()) + .header("Pragma", "no-cache") + .body(Map.of( + "headerName", csrfToken.getHeaderName(), + "parameterName", csrfToken.getParameterName(), + "token", csrfToken.getToken() + )); + } +} diff --git a/bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java b/bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java new file mode 100644 index 0000000..31f9879 --- /dev/null +++ b/bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java @@ -0,0 +1,85 @@ +package com.example.keycloakpattern.bff; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; + +@Configuration +public class SecurityConfig { + + @Bean + SecurityFilterChain bffSecurity( + HttpSecurity http, + ClientRegistrationRepository clientRegistrationRepository + ) throws Exception { + DefaultOAuth2AuthorizationRequestResolver authorizationRequestResolver = + new DefaultOAuth2AuthorizationRequestResolver( + clientRegistrationRepository, + "/oauth2/authorization" + ); + authorizationRequestResolver.setAuthorizationRequestCustomizer( + OAuth2AuthorizationRequestCustomizers.withPkce() + ); + + CookieCsrfTokenRepository csrfTokenRepository = + CookieCsrfTokenRepository.withHttpOnlyFalse(); + csrfTokenRepository.setCookiePath("/"); + + return http + .csrf(csrf -> csrf + .csrfTokenRepository(csrfTokenRepository) + .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers( + "/", + "/index.html", + "/app.js", + "/favicon.ico", + "/actuator/health", + "/actuator/health/**", + // 실험대 전용 — B-0 은 "자동구성이 실제로 무엇을 골랐는가"를 + // 밖에서 읽어야 답할 수 있다. 운영에서는 절대 열지 않는다: + // /actuator/beans 와 /actuator/env 는 내부 구조와 설정값을 + // 그대로 드러낸다. + "/actuator/**" + ) + .permitAll() + .anyRequest() + .authenticated()) + .oauth2Login(oauth2 -> oauth2 + .authorizationEndpoint(endpoint -> endpoint + .authorizationRequestResolver(authorizationRequestResolver)) + .defaultSuccessUrl("/", true)) + .build(); + } + + @Bean + OAuth2AuthorizedClientManager authorizedClientManager( + ClientRegistrationRepository clientRegistrationRepository, + OAuth2AuthorizedClientService authorizedClientService + ) { + OAuth2AuthorizedClientProvider authorizedClientProvider = + OAuth2AuthorizedClientProviderBuilder.builder() + .authorizationCode() + .refreshToken() + .build(); + + AuthorizedClientServiceOAuth2AuthorizedClientManager manager = + new AuthorizedClientServiceOAuth2AuthorizedClientManager( + clientRegistrationRepository, + authorizedClientService + ); + manager.setAuthorizedClientProvider(authorizedClientProvider); + return manager; + } +} diff --git a/bff/src/main/java/com/example/keycloakpattern/bff/SpaCsrfTokenRequestHandler.java b/bff/src/main/java/com/example/keycloakpattern/bff/SpaCsrfTokenRequestHandler.java new file mode 100644 index 0000000..3072fc5 --- /dev/null +++ b/bff/src/main/java/com/example/keycloakpattern/bff/SpaCsrfTokenRequestHandler.java @@ -0,0 +1,40 @@ +package com.example.keycloakpattern.bff; + +import java.util.function.Supplier; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; +import org.springframework.security.web.csrf.CsrfTokenRequestHandler; +import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler; +import org.springframework.util.StringUtils; + +final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler { + + private final CsrfTokenRequestHandler plain = + new CsrfTokenRequestAttributeHandler(); + private final CsrfTokenRequestHandler xor = + new XorCsrfTokenRequestAttributeHandler(); + + @Override + public void handle( + HttpServletRequest request, + HttpServletResponse response, + Supplier deferredCsrfToken + ) { + xor.handle(request, response, deferredCsrfToken); + } + + @Override + public String resolveCsrfTokenValue( + HttpServletRequest request, + CsrfToken csrfToken + ) { + if (StringUtils.hasText(request.getHeader(csrfToken.getHeaderName()))) { + return plain.resolveCsrfTokenValue(request, csrfToken); + } + return xor.resolveCsrfTokenValue(request, csrfToken); + } +} diff --git a/bff/src/main/resources/application.yml b/bff/src/main/resources/application.yml new file mode 100644 index 0000000..b2d21e4 --- /dev/null +++ b/bff/src/main/resources/application.yml @@ -0,0 +1,52 @@ +server: + port: ${SERVER_PORT:8083} + servlet: + session: + cookie: + name: AP3_SESSION + http-only: true + same-site: lax + +spring: + application: + name: keycloak-bff + security: + oauth2: + client: + registration: + keycloak: + provider: keycloak + client-id: bff-confidential + client-secret: ${KEYCLOAK_CLIENT_SECRET} + client-authentication-method: client_secret_basic + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + scope: + - openid + - profile + - email + provider: + keycloak: + # 브라우저가 리다이렉트되는 주소와 BFF 가 서버끼리 부르는 주소는 다르다. + # 앞의 것은 외부에서 닿는 이름이어야 하고, 뒤의 것은 클러스터 안 주소여도 된다. + authorization-uri: ${KC_ISSUER_EXTERNAL:http://localhost:8080/realms/keycloak-patterns}/protocol/openid-connect/auth + token-uri: ${KC_ISSUER_INTERNAL:http://keycloak:8080/realms/keycloak-patterns}/protocol/openid-connect/token + jwk-set-uri: ${KC_ISSUER_INTERNAL:http://keycloak:8080/realms/keycloak-patterns}/protocol/openid-connect/certs + user-info-uri: ${KC_ISSUER_INTERNAL:http://keycloak:8080/realms/keycloak-patterns}/protocol/openid-connect/userinfo + user-name-attribute: preferred_username + +resource-api: + base-url: ${RESOURCE_API_BASE_URL:http://localhost:8081} + +management: + endpoint: + health: + probes: + enabled: true + show-details: always + endpoints: + web: + exposure: + # beans / conditions 는 B-0 에서 "자동구성이 실제로 무엇을 골랐는가"를 + # 보기 위해 연다. 운영에 그대로 두면 내부 구조가 노출된다. + include: health,info,beans,conditions,env diff --git a/bff/src/main/resources/static/app.js b/bff/src/main/resources/static/app.js new file mode 100644 index 0000000..c6bf200 --- /dev/null +++ b/bff/src/main/resources/static/app.js @@ -0,0 +1,59 @@ +const result = document.querySelector("#result"); + +function render(value) { + result.textContent = JSON.stringify(value, null, 2); +} + +function readCookie(name) { + const prefix = `${encodeURIComponent(name)}=`; + const value = document.cookie + .split("; ") + .find((cookie) => cookie.startsWith(prefix)); + return value ? decodeURIComponent(value.slice(prefix.length)) : null; +} + +async function request(path, options = {}) { + const response = await fetch(path, { + ...options, + headers: { Accept: "application/json", ...options.headers }, + }); + if (response.redirected || response.status === 401) { + window.location.assign("/oauth2/authorization/keycloak"); + return null; + } + const body = await response.json(); + render({ status: response.status, ...body }); + return { response, body }; +} + +document.querySelector("#login").addEventListener("click", () => { + window.location.assign("/oauth2/authorization/keycloak"); +}); + +document.querySelector("#inspect").addEventListener("click", () => { + void request("/bff/token-boundary"); +}); + +document.querySelector("#call-bff").addEventListener("click", () => { + void request("/bff/api/me"); +}); + +document.querySelector("#change-with-csrf").addEventListener("click", async () => { + const csrfResponse = await fetch("/bff/csrf", { + headers: { Accept: "application/json" }, + }); + const csrf = await csrfResponse.json(); + const csrfToken = readCookie("XSRF-TOKEN"); + if (!csrfToken) { + render({ status: 500, error: "XSRF-TOKEN cookie was not created" }); + return; + } + await request("/bff/api/preferences", { + method: "POST", + body: new URLSearchParams({ theme: "dark" }), + headers: { + "Content-Type": "application/x-www-form-urlencoded", + [csrf.headerName]: csrfToken, + }, + }); +}); diff --git a/bff/src/main/resources/static/index.html b/bff/src/main/resources/static/index.html new file mode 100644 index 0000000..f055b61 --- /dev/null +++ b/bff/src/main/resources/static/index.html @@ -0,0 +1,31 @@ + + + + + + AP3 · Backend-for-Frontend + + + +
+

AP3 · Backend-for-Frontend

+

+ 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session + cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource + Server 요청에 붙입니다. +

+ + + + +

+  
+ + + diff --git a/bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java b/bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java new file mode 100644 index 0000000..6cd928e --- /dev/null +++ b/bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java @@ -0,0 +1,90 @@ +package com.example.keycloakpattern.bff; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.security.oauth2.core.OAuth2RefreshToken; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest(properties = { + "KEYCLOAK_CLIENT_SECRET=test-only-secret", + "resource-api.base-url=http://127.0.0.1:9" +}) +@AutoConfigureMockMvc +class BffControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private OAuth2AuthorizedClientService authorizedClientService; + + @MockitoBean + private OAuth2AuthorizedClientManager authorizedClientManager; + + @Test + void reportsServerTokenCustodyWithoutReturningTokens() throws Exception { + OAuth2AuthorizedClient client = mock(OAuth2AuthorizedClient.class); + when(client.getAccessToken()).thenReturn(mock(OAuth2AccessToken.class)); + when(client.getRefreshToken()).thenReturn(mock(OAuth2RefreshToken.class)); + when(authorizedClientService.loadAuthorizedClient("keycloak", "test-subject")) + .thenReturn(client); + + mockMvc.perform(get("/bff/token-boundary").with(oidcLogin() + .idToken(token -> token.subject("test-subject")))) + .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", "no-store")) + .andExpect(jsonPath("$.accessTokenStoredOnServer").value(true)) + .andExpect(jsonPath("$.refreshTokenStoredOnServer").value(true)) + .andExpect(jsonPath("$.browserTokenCount").value(0)) + .andExpect(jsonPath("$.csrfProtectionEnabled").value(true)) + .andExpect(jsonPath("$.access_token").doesNotExist()) + .andExpect(jsonPath("$.refresh_token").doesNotExist()); + } + + @Test + void rejectsStateChangeWithoutCsrfToken() throws Exception { + mockMvc.perform(post("/bff/api/preferences") + .param("theme", "attacker") + .with(oidcLogin().idToken(token -> token.subject("test-subject")))) + .andExpect(status().isForbidden()); + } + + @Test + void acceptsStateChangeWithCsrfToken() throws Exception { + mockMvc.perform(post("/bff/api/preferences") + .param("theme", "dark") + .with(oidcLogin().idToken(token -> token.subject("test-subject"))) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.updated").value(true)) + .andExpect(jsonPath("$.theme").value("dark")); + } + + @Test + void exposesSpaCsrfTokenWithoutCaching() throws Exception { + mockMvc.perform(get("/bff/csrf").with(oidcLogin() + .idToken(token -> token.subject("test-subject")))) + .andExpect(status().isOk()) + .andExpect(header().string("Cache-Control", "no-store")) + .andExpect(header().exists("Set-Cookie")) + .andExpect(jsonPath("$.headerName").value("X-XSRF-TOKEN")) + .andExpect(jsonPath("$.token").isNotEmpty()); + } +} diff --git a/deploy/lab/k8s/bff-redis.yaml b/deploy/lab/k8s/bff-redis.yaml new file mode 100644 index 0000000..6974f98 --- /dev/null +++ b/deploy/lab/k8s/bff-redis.yaml @@ -0,0 +1,161 @@ +# BFF (2 replicas) + Redis, for the B-layer experiments. +# +# The BFF is deployed FIRST WITHOUT any session store wiring. That is deliberate: +# B-0 asks what Spring Boot's autoconfiguration actually picks when nothing is +# configured, and the only honest way to answer is to look at a running instance +# that has been given nothing. Redis is deployed alongside but left unused until +# B-1 turns it on. +# +# kubectl apply -f deploy/lab/k8s/bff-redis.yaml +# +# Image comes from the workstation, not a registry: +# docker build -t keycloak-pattern-bff:lab bff/ +# docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'" +# (repeat for kc-lab-2) +# so imagePullPolicy must stay Never on both replicas. +apiVersion: v1 +kind: Secret +metadata: + name: bff-secrets + namespace: keycloak-lab +type: Opaque +stringData: + # Matches the client created with kcadm in the keycloak-patterns realm. + # Base64 in etcd is not encryption — see D-3. + KEYCLOAK_CLIENT_SECRET: bff-lab-secret +--- +# Redis. No persistence yet: `--save ""` and no appendonly, so a restart loses +# everything. B-5 and B-6 compare that against RDB and AOF, which is easier to +# reason about when the starting point is "nothing survives". +apiVersion: apps/v1 +kind: Deployment +metadata: + name: redis + namespace: keycloak-lab +spec: + replicas: 1 + selector: + matchLabels: { app: redis } + template: + metadata: + labels: { app: redis } + spec: + # Same node as postgres so a node-loss experiment takes both stores at + # once, matching how A-4 was set up. + nodeSelector: + kubernetes.io/hostname: kc-lab-2 + containers: + - name: redis + image: redis:7.4-alpine + args: ["redis-server", "--save", "", "--appendonly", "no"] + ports: + - containerPort: 6379 + name: redis + readinessProbe: + exec: { command: ["redis-cli", "ping"] } + initialDelaySeconds: 3 + resources: + requests: { memory: 32Mi, cpu: 20m } + limits: { memory: 128Mi } +--- +apiVersion: v1 +kind: Service +metadata: + name: redis + namespace: keycloak-lab +spec: + selector: { app: redis } + ports: + - port: 6379 + targetPort: redis +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: bff + namespace: keycloak-lab +spec: + # Two replicas is the whole point: Q1 and Q2 only exist because a request can + # land on an instance that did not handle the login. + replicas: 2 + selector: + matchLabels: { app: bff } + template: + metadata: + labels: { app: bff } + spec: + # Spread across both nodes so "the other instance" is genuinely another + # machine, not another process on the same kernel. + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: { app: bff } + containers: + - name: bff + image: keycloak-pattern-bff:lab + imagePullPolicy: Never + ports: + - containerPort: 8083 + name: http + env: + # The browser is redirected to the public name; the BFF calls the + # token endpoint over the cluster network. Getting these two the same + # way round is what the 2-hop header experiment was about. + - name: KC_ISSUER_EXTERNAL + value: https://auth.hyeonworks.com/realms/keycloak-patterns + - name: KC_ISSUER_INTERNAL + value: http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns + - name: RESOURCE_API_BASE_URL + value: http://echo.keycloak-lab.svc:8080 + - name: KEYCLOAK_CLIENT_SECRET + valueFrom: + secretKeyRef: { name: bff-secrets, key: KEYCLOAK_CLIENT_SECRET } + # Spring needs to know it is behind TLS termination, for the same + # reason Keycloak needs KC_PROXY_HEADERS. Without it the redirect_uri + # it builds comes back as http:// and Keycloak rejects it. + - name: SERVER_FORWARD_HEADERS_STRATEGY + value: native + - name: JAVA_TOOL_OPTIONS + value: "-Xms128m -Xmx320m" + readinessProbe: + httpGet: { path: /actuator/health/readiness, port: http } + initialDelaySeconds: 20 + failureThreshold: 30 + livenessProbe: + httpGet: { path: /actuator/health/liveness, port: http } + initialDelaySeconds: 60 + resources: + requests: { memory: 320Mi, cpu: 100m } + limits: { memory: 512Mi } +--- +apiVersion: v1 +kind: Service +metadata: + name: bff + namespace: keycloak-lab +spec: + selector: { app: bff } + ports: + - port: 8083 + targetPort: http +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: bff + namespace: keycloak-lab +spec: + ingressClassName: traefik + rules: + - host: app1.hyeonworks.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: bff + port: + number: 8083 diff --git a/docs/evidence/b0-bff-redis-deploy/01-deploy.txt b/docs/evidence/b0-bff-redis-deploy/01-deploy.txt new file mode 100644 index 0000000..a57adae --- /dev/null +++ b/docs/evidence/b0-bff-redis-deploy/01-deploy.txt @@ -0,0 +1,21 @@ +=== 배포 전 자원 === +Mem: 11648 7329 280 4 4377 4319 +NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%) +kc-lab-1 115m 5% 2192Mi 44% +kc-lab-2 121m 6% 1324Mi 33% + +=== 배포 === +secret/bff-secrets created +deployment.apps/redis created +service/redis created +deployment.apps/bff created +service/bff created +ingress.networking.k8s.io/bff created + +deployment "redis" successfully rolled out +Waiting for deployment "bff" rollout to finish: 1 of 2 updated replicas are available... +deployment "bff" successfully rolled out + +bff-574c6d658b-8cz4x true kc-lab-1 +bff-574c6d658b-zpkbp true kc-lab-2 +redis-568bd7c4-5c5vc true kc-lab-2 diff --git a/docs/evidence/b0-bff-redis-deploy/02-autoconfiguration.txt b/docs/evidence/b0-bff-redis-deploy/02-autoconfiguration.txt new file mode 100644 index 0000000..f21067d --- /dev/null +++ b/docs/evidence/b0-bff-redis-deploy/02-autoconfiguration.txt @@ -0,0 +1,13 @@ +=== B-0: 자동구성이 실제로 고른 구현체 === + Q1 확인한 사실: "코드에 저장소를 직접 생성하는 Bean 이 없기 때문에, + 어떤 구현체가 실제로 사용되는지는 자동구성 결과까지 확인해야 정확하게 알 수 있다" + + File "", line 9 + print(f" {name:46} {t.rsplit(\".\",1)[-1]}") + ^ +SyntaxError: unexpected character after line continuation character + +=== HttpSession 은 어디에 있는가 (서블릿 컨테이너 기본) === + +=== 외부 진입점 === + https://app1.hyeonworks.com/ HTTP 200 diff --git a/docs/evidence/b0-bff-redis-deploy/03-beans-analysis.txt b/docs/evidence/b0-bff-redis-deploy/03-beans-analysis.txt new file mode 100644 index 0000000..38d8eb3 --- /dev/null +++ b/docs/evidence/b0-bff-redis-deploy/03-beans-analysis.txt @@ -0,0 +1,29 @@ +=== B-0 — 자동구성이 실제로 고른 구현체 === + 컨텍스트: keycloak-bff + 전체 빈 수: 321 + + --- 세션 · 토큰 저장소 관련 --- + authorizedClientManager -> AuthorizedClientServiceOAuth2AuthorizedClientManager + authorizedClientManagerRegistrar -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerRegistrar + authorizedClientRepository -> AuthenticatedPrincipalOAuth2AuthorizedClientRepository + authorizedClientService -> InMemoryOAuth2AuthorizedClientService + org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientConfigurations$OAuth2AuthorizedClientServiceConfiguration -> OAuth2ClientConfigurations$OAuth2AuthorizedClientServiceConfiguration + org.springframework.security.config.annotation.web.configuration.OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerConfiguration -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerConfiguration + + --- OAuth2 클라이언트 관련 전체 --- + authorizedClientManager -> AuthorizedClientServiceOAuth2AuthorizedClientManager + authorizedClientManagerRegistrar -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerRegistrar + authorizedClientRepository -> AuthenticatedPrincipalOAuth2AuthorizedClientRepository + authorizedClientService -> InMemoryOAuth2AuthorizedClientService + clientRegistrationRepository -> InMemoryClientRegistrationRepository + org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientAutoConfiguration -> OAuth2ClientAutoConfiguration + org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientConfigurations$ClientRegistrationRepositoryConfiguration -> OAuth2ClientConfigurations$ClientRegistrationRepositoryConfiguration + org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientConfigurations$OAuth2AuthorizedClientServiceConfiguration -> OAuth2ClientConfigurations$OAuth2AuthorizedClientServiceConfiguration + org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientWebSecurityAutoConfiguration -> OAuth2ClientWebSecurityAutoConfiguration + org.springframework.security.config.annotation.web.configuration.OAuth2ClientConfiguration -> OAuth2ClientConfiguration + org.springframework.security.config.annotation.web.configuration.OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerConfiguration -> OAuth2ClientConfiguration$OAuth2AuthorizedClientManagerConfiguration + org.springframework.security.config.annotation.web.configuration.OAuth2ClientConfiguration$OAuth2ClientWebMvcSecurityConfiguration -> OAuth2ClientConfiguration$OAuth2ClientWebMvcSecurityConfiguration + spring.security.oauth2.client-org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties -> OAuth2ClientProperties + + --- Redis / Spring Session 이 구성되었는가 --- + ★ 없음 — Redis 도 Spring Session 도 구성되지 않았다 diff --git a/docs/evidence/b0-bff-redis-deploy/README.md b/docs/evidence/b0-bff-redis-deploy/README.md new file mode 100644 index 0000000..b3753f7 --- /dev/null +++ b/docs/evidence/b0-bff-redis-deploy/README.md @@ -0,0 +1,18 @@ +# B-0 — BFF·Redis 배포와 자동구성 확인 증거 + +2026-09-04 14:20–14:50 KST +해설: [`docs/experiment-b0-bff-redis-deploy.md`](../../experiment-b0-bff-redis-deploy.md) + +| 파일 | 무엇을 보여주는가 | +|---|---| +| `01-deploy.txt` | 배포 전 자원, Redis·BFF 롤아웃, 두 노드에 하나씩 배치됨 | +| `02-autoconfiguration.txt` | 첫 조회 시도(파싱 실패)와 **외부 진입점 `HTTP 200`** | +| `03-beans-analysis.txt` | **B-0 의 답** — `InMemoryOAuth2AuthorizedClientService`, `AuthenticatedPrincipalOAuth2AuthorizedClientRepository`, **Redis·Spring Session 없음** | +| `b0-bff-login-success-single-replica.png` | replica 1 에서 로그인 성공한 BFF 화면 | +| `b0-bff-token-boundary.png` | `/bff/token-boundary` — `principal: labuser`, `accessTokenStoredOnServer: true`, **`browserTokenCount: 0`** | + +## 핵심 세 줄 + +1. **`AuthenticatedPrincipalOAuth2AuthorizedClientRepository`** — 조회 키가 principal 이고 session ID 가 없다. Q1·Q3 문제의 기제가 이 빈 하나에 있다. +2. **Redis 를 붙여도 그건 안 고쳐진다.** 저장소 공유와 조회 키는 다른 문제다. +3. **replica 2개에서는 로그인 자체가 실패한다.** 인가 코드 흐름의 왕복 두 번이 같은 인스턴스로 가야 하는데, 인가 요청이 인스턴스 메모리에 있다. diff --git a/docs/evidence/b0-bff-redis-deploy/b0-bff-login-success-single-replica.png b/docs/evidence/b0-bff-redis-deploy/b0-bff-login-success-single-replica.png new file mode 100644 index 0000000..f5faa70 Binary files /dev/null and b/docs/evidence/b0-bff-redis-deploy/b0-bff-login-success-single-replica.png differ diff --git a/docs/evidence/b0-bff-redis-deploy/b0-bff-token-boundary.png b/docs/evidence/b0-bff-redis-deploy/b0-bff-token-boundary.png new file mode 100644 index 0000000..cc0a4f0 Binary files /dev/null and b/docs/evidence/b0-bff-redis-deploy/b0-bff-token-boundary.png differ diff --git a/docs/experiment-b0-bff-redis-deploy.md b/docs/experiment-b0-bff-redis-deploy.md new file mode 100644 index 0000000..4c0e189 --- /dev/null +++ b/docs/experiment-b0-bff-redis-deploy.md @@ -0,0 +1,283 @@ +# B-0 — 자동구성은 실제로 무엇을 골랐는가 (그리고 배포에서 겪은 것들) + +브랜치 `feature/keycloak-b0-bff-redis-deploy` · +증거 [`docs/evidence/b0-bff-redis-deploy/`](evidence/b0-bff-redis-deploy/) · +2026-09-04 14:20–14:50 KST + +**Q1 이 직접 요구한 확인이다.** + +> 코드에 저장소를 직접 생성하는 Bean 이 없기 때문에, 어떤 구현체가 실제로 +> 사용되는지는 **Spring Boot 의 자동구성 결과까지 확인해야** 정확하게 알 수 있다. + +--- + +## 0. 결론부터 + +``` +authorizedClientService -> InMemoryOAuth2AuthorizedClientService +authorizedClientRepository -> AuthenticatedPrincipalOAuth2AuthorizedClientRepository +authorizedClientManager -> AuthorizedClientServiceOAuth2AuthorizedClientManager +clientRegistrationRepository -> InMemoryClientRegistrationRepository + +SessionRepository -> 없음 (서블릿 컨테이너 in-memory) +Redis / Spring Session -> ★ 없음 +``` + +**추측이 맞았지만, 추측으로 두면 안 되는 이유가 두 번째 줄에 있다.** + +`AuthenticatedPrincipalOAuth2AuthorizedClientRepository` — 이름이 곧 설명이다. +**"인증된 주체(principal) 기준"** 으로 authorized client 를 찾는다. +**session ID 가 아니다.** Q1·Q3 가 지적한 "같은 사용자의 여러 브라우저가 같은 +token 을 공유한다"는 문제의 **기제가 이 빈 하나에 들어 있다.** + +그리고 배포하자마자 **Q1 의 문제가 실험을 시작하기도 전에 나타났다** — +replica 2개에서는 **로그인 자체가 실패한다.** + +--- + +## 1. 배포에서 겪은 문제 다섯 가지 + +### 문제 ① — `bff/` 가 소스 없이 빌드 산출물만 있었다 + +``` +bff/target/classes/... 9개 파일 +bff/src/ 없음 +``` + +`.gitignore` 에 `target/` 이 없어 클래스 파일만 커밋되어 있었다. +소스는 다른 브랜치에 있었다. + +```bash +git checkout origin/develop-keycloak-pattern3 -- bff/ +``` + +### 문제 ② — YAML 중복 키로 빌드가 깨졌다 + +actuator 를 열려고 `management:` 아래에 `endpoint:` 블록을 **하나 더** 넣었다. +이미 있는데. + +``` +org.yaml.snakeyaml.constructor.SafeConstructor.processDuplicateKeys +``` + +**Docker 빌드 로그가 `tail` 로 잘려 원인이 안 보였다.** `--progress=plain` 으로 +전체를 받아서야 스택트레이스에서 `processDuplicateKeys` 를 찾았다. + +```bash +docker build --progress=plain -t keycloak-pattern-bff:lab . > /tmp/build.log 2>&1 +grep -nE "Tests run|Caused by|\.java:[0-9]" /tmp/build.log +``` + +> **빌드 실패는 마지막 15줄에 안 들어 있는 경우가 많다.** 전체를 파일로 받는다. + +### 문제 ③ — 환경변수에 기본값을 안 줘서 테스트가 죽었다 + +`${KC_ISSUER_EXTERNAL}` 처럼 기본값 없이 쓰면 **테스트에서 컨텍스트가 안 뜬다.** +테스트는 그 환경변수를 모른다. + +```yaml +authorization-uri: ${KC_ISSUER_EXTERNAL:http://localhost:8080/realms/keycloak-patterns}/protocol/openid-connect/auth +``` + +### 문제 ④ — actuator 가 인증에 막혀 있었다 + +`/actuator/beans` 를 부르면 `200` 이 왔는데, **Keycloak 로그인 페이지**였다. +`-L` 로 리다이렉트를 따라간 결과였다. + +```java +"/actuator/health", +"/actuator/health/**", +// 실험대 전용 — 운영에서는 절대 열지 않는다 +"/actuator/**" +``` + +> **`200` 이 곧 성공은 아니다.** 무엇이 왔는지 봐야 한다. + +### 문제 ⑤ — 큰 응답이 프록시에서 `Bad Gateway` + +`/actuator/beans` 는 117KB 다. nginx → Traefik 을 거치면서 실패했다. + +``` +$ curl https://app1.hyeonworks.com/actuator/beans +Bad Gateway +``` + +파드 안에서 직접 받아 해결했다. **alpine 기반 JRE 이미지에 `wget` 이 있다.** + +```bash +kubectl -n keycloak-lab exec -- wget -qO- http://localhost:8083/actuator/beans +``` + +--- + +## 2. 배포 구성 + +``` + 브라우저 ──https──▶ nginx ──▶ Traefik ──▶ bff (2 replica) + │ + ├──▶ Keycloak (realm: keycloak-patterns) + └──▶ echo (resource server 대역) + + redis ── kc-lab-2 (postgres 와 같은 노드) ← 아직 연결하지 않았다 +``` + +**Redis 는 배포만 하고 BFF 에 연결하지 않았다.** B-0 의 질문이 "아무것도 주지 +않았을 때 자동구성이 무엇을 고르는가"이므로, 아무것도 주지 않은 상태를 먼저 +측정해야 한다. + +### Keycloak realm 준비 (kcadm) + +```bash +kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh config credentials \ + --server http://localhost:8080 --realm master --user admin --password + +kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh create realms \ + -s realm=keycloak-patterns -s enabled=true -s accessTokenLifespan=60 + +kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh create clients -r keycloak-patterns \ + -s clientId=bff-confidential -s publicClient=false -s secret=bff-lab-secret \ + -s 'redirectUris=["https://app1.hyeonworks.com/*"]' +``` + +**`accessTokenLifespan=60`** 으로 둔 것은 B-3(refresh 경쟁)을 위해서다. +만료를 기다리는 시간이 짧아야 재현이 된다. + +### 브라우저용 URL 과 백채널 URL 을 분리했다 + +```yaml +authorization-uri: ${KC_ISSUER_EXTERNAL}/protocol/openid-connect/auth # 브라우저가 간다 +token-uri: ${KC_ISSUER_INTERNAL}/protocol/openid-connect/token # BFF 가 서버끼리 +``` + +```yaml +- name: KC_ISSUER_EXTERNAL + value: https://auth.hyeonworks.com/realms/keycloak-patterns +- name: KC_ISSUER_INTERNAL + value: http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns +``` + +**2홉 헤더 실험에서 배운 것이 그대로 쓰인다** — 브라우저가 보는 이름과 +서버가 부르는 주소는 다르고, 섞으면 리다이렉트가 깨진다. +`SERVER_FORWARD_HEADERS_STRATEGY=native` 도 같은 이유다. 없으면 Spring 이 +`redirect_uri` 를 `http://` 로 만들어 Keycloak 이 거부한다. + +--- + +## 3. 동작 확인 — 브라우저 증거 + +![로그인 성공](evidence/b0-bff-redis-deploy/b0-bff-login-success-single-replica.png) + +![token 경계](evidence/b0-bff-redis-deploy/b0-bff-token-boundary.png) + +```json +{"pattern":"AP3-backend-for-frontend","principal":"labuser", + "accessTokenStoredOnServer":true,"refreshTokenStoredOnServer":true, + "browserTokenCount":0,"csrfProtectionEnabled":true} +``` + +**BFF 패턴이 성립한다** — 브라우저에 토큰이 0개이고, 서버가 access/refresh 를 +들고 있다. + +--- + +## 4. B-0 의 답 — 자동구성 결과 + +전체 빈 321개 중 관련된 것들이다. + +| 빈 | 구현체 | 뜻 | +|---|---|---| +| `authorizedClientService` | **`InMemoryOAuth2AuthorizedClientService`** | **프로세스 메모리.** 재시작하면 사라진다 | +| `authorizedClientRepository` | **`AuthenticatedPrincipalOAuth2AuthorizedClientRepository`** | **principal 기준 조회.** session ID 가 없다 | +| `authorizedClientManager` | `AuthorizedClientServiceOAuth2AuthorizedClientManager` | **service**(공유) 를 쓴다 | +| `clientRegistrationRepository` | `InMemoryClientRegistrationRepository` | 설정에서 읽은 것 | +| SessionRepository | **없음** | Tomcat 의 기본 `StandardSession` | +| Redis / Spring Session | **없음** | 의존성 자체가 없다 | + +### `AuthenticatedPrincipalOAuth2AuthorizedClientRepository` 가 핵심이다 + +``` + 요청이 인증되어 있으면 + └─▶ OAuth2AuthorizedClientService 에 위임 + └─▶ 키: (clientRegistrationId, principalName) + └─ session ID 가 없다 ★ + 인증되어 있지 않으면 + └─▶ HttpSession 에 임시 보관 +``` + +**같은 사용자가 두 브라우저에서 로그인하면 principalName 이 같으므로 +같은 항목을 본다.** Q1 의 미지수 3 과 Q3 의 제약이 여기서 나온다. + +> **Redis 를 붙여도 이건 안 고쳐진다.** 저장소를 공유해도 **키에 session ID 가 +> 없기 때문**이다. Q1 이 "Session Store 를 공유 저장소로 바꾸는 것만으로는 +> 충분하지 않다"고 쓴 이유다. + +--- + +## 5. 예상 못 한 것 — **replica 2개에서 로그인 자체가 안 된다** + +배포 직후 브라우저에서 로그인하니 `/login?error` 로 떨어졌다. +BFF 로그에는 아무 오류도 없었다 (Spring Security 는 로그인 실패를 DEBUG 로만 남긴다). + +**가설** — 인가 요청(state, PKCE verifier)은 `HttpSession` 에 저장된다. +그런데 그 세션은 **인스턴스 메모리**다. 콜백이 다른 replica 로 가면 저장된 +인가 요청이 없어 실패한다. + +**검증** — replica 를 1로 줄이고 다시 시도했다. + +```bash +kubectl -n keycloak-lab scale deployment/bff --replicas=1 +``` + +**로그인이 성공했다.** 가설 확정. + +``` + replica 2 + 스티키 없음 → 로그인 실패 (콜백이 다른 인스턴스로) + replica 1 → 로그인 성공 +``` + +> **Q1 의 문제가 실험을 시작하기도 전에 나타났다.** +> "다중 인스턴스에서 어떻게 운영할 것인가"는 **로그인한 뒤의 문제가 아니라 +> 로그인 자체의 문제**다. 인가 코드 흐름은 **왕복 두 번**이고, 두 번 다 같은 +> 인스턴스로 가야 한다. +> +> 이건 B-2 의 검증 1번("한쪽에서 로그인한 뒤 다른 인스턴스로 요청")보다 +> **앞선 단계**다. 로그인이 끝나야 그 검증을 할 수 있는데, 로그인부터 막힌다. + +--- + +## 6. 재현 절차 (명령어) + +```bash +# 1. 소스 가져오기 (target/ 만 커밋되어 있었다) +git checkout origin/develop-keycloak-pattern3 -- bff/ + +# 2. 빌드 — 실패하면 전체 로그를 파일로 +docker build --progress=plain -t keycloak-pattern-bff:lab bff/ > /tmp/build.log 2>&1 +grep -nE "Tests run|Caused by" /tmp/build.log + +# 3. 두 노드에 적재 (레지스트리 없음 → imagePullPolicy: Never) +docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'" +docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'" + +# 4. realm · client · user +kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh create realms -s realm=keycloak-patterns ... + +# 5. 배포 +kubectl apply -f deploy/lab/k8s/bff-redis.yaml + +# 6. 자동구성 결과 — 파드 안에서 (프록시는 큰 응답에서 502) +kubectl -n keycloak-lab exec -- wget -qO- http://localhost:8083/actuator/beans > beans.json +python3 -c "import json;d=json.load(open('beans.json'));[print(n,'->',i['type']) for n,i in + list(d['contexts'].values())[0]['beans'].items() if 'AuthorizedClient' in i['type']]" +``` + +--- + +## 7. 다음 실험에 남기는 것 + +| 실험 | 이 실험이 준 것 | +|---|---| +| **B-1** 저장소 결정 | **전환 후 이 빈들이 바뀌는지 다시 찍는다.** "Redis 붙였다"고 믿는데 자동구성이 안 걸리는 경우가 흔하다 | +| **B-2** 다중 인스턴스 | **로그인 자체가 실패한다**는 것이 이미 관측됐다. 그것이 검증 0번이다 | +| **B-3** refresh 경쟁 | `accessTokenLifespan=60` 으로 realm 을 만들어뒀다 | +| 운영 | actuator `beans`/`env` 는 **내부 구조를 그대로 드러낸다.** 실험대에서만 연다 |