From e62bbb4df02c3e0e58e9ad15985dcbf0e23a3e78 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Fri, 4 Sep 2026 13:48:33 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20B-0=20=E2=80=94=20deploy=20the=20BFF=20?= =?UTF-8?q?and=20read=20what=20autoconfiguration=20actually=20chose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The authorized client repository is AuthenticatedPrincipalOAuth2AuthorizedClientRepository, keyed by principal with no session id in it, which is the mechanism behind the sharing problem Q1 and Q3 describe. Sharing a store does not fix a lookup key. Five problems on the way in: only build output was committed under bff/, a duplicate YAML key broke the image build and was invisible until the full log was captured, env placeholders without defaults broke the tests, actuator was behind the login redirect so a 200 was the login page, and the 117KB beans response failed through the proxy. Deploying two replicas made the login itself fail before any experiment started, because the authorization request lives in per-instance memory and the callback lands elsewhere. Co-Authored-By: Claude Opus 5 --- .../console-2026-09-04T04-19-58-373Z.log | 59 ++++ .../console-2026-09-04T04-46-50-423Z.log | 1 + .../page-2026-09-04T04-44-07-075Z.yml | 7 + .../page-2026-09-04T04-44-20-764Z.yml | 16 + .../page-2026-09-04T04-44-32-327Z.yml | 20 ++ .../page-2026-09-04T04-44-52-039Z.yml | 20 ++ .../page-2026-09-04T04-45-23-537Z.yml | 7 + .../page-2026-09-04T04-45-28-992Z.yml | 16 + .../page-2026-09-04T04-45-37-881Z.yml | 9 + .../page-2026-09-04T04-46-14-914Z.yml | 7 + .../page-2026-09-04T04-46-19-545Z.yml | 7 + .../page-2026-09-04T04-46-29-422Z.yml | 7 + .../page-2026-09-04T04-46-50-621Z.yml | 1 + bff/.dockerignore | 1 + bff/Dockerfile | 14 + bff/pom.xml | 58 ++++ .../keycloakpattern/bff/BffApplication.java | 12 + .../keycloakpattern/bff/BffController.java | 112 +++++++ .../keycloakpattern/bff/CsrfController.java | 25 ++ .../keycloakpattern/bff/SecurityConfig.java | 85 ++++++ .../bff/SpaCsrfTokenRequestHandler.java | 40 +++ bff/src/main/resources/application.yml | 52 ++++ bff/src/main/resources/static/app.js | 59 ++++ bff/src/main/resources/static/index.html | 31 ++ .../bff/BffControllerTest.java | 90 ++++++ deploy/lab/k8s/bff-redis.yaml | 161 ++++++++++ .../b0-bff-redis-deploy/01-deploy.txt | 21 ++ .../02-autoconfiguration.txt | 13 + .../b0-bff-redis-deploy/03-beans-analysis.txt | 29 ++ docs/evidence/b0-bff-redis-deploy/README.md | 18 ++ .../b0-bff-login-success-single-replica.png | Bin 0 -> 34830 bytes .../b0-bff-token-boundary.png | Bin 0 -> 19256 bytes docs/experiment-b0-bff-redis-deploy.md | 283 ++++++++++++++++++ 33 files changed, 1281 insertions(+) create mode 100644 .playwright-mcp/console-2026-09-04T04-46-50-423Z.log create mode 100644 .playwright-mcp/page-2026-09-04T04-44-07-075Z.yml create mode 100644 .playwright-mcp/page-2026-09-04T04-44-20-764Z.yml create mode 100644 .playwright-mcp/page-2026-09-04T04-44-32-327Z.yml create mode 100644 .playwright-mcp/page-2026-09-04T04-44-52-039Z.yml create mode 100644 .playwright-mcp/page-2026-09-04T04-45-23-537Z.yml create mode 100644 .playwright-mcp/page-2026-09-04T04-45-28-992Z.yml create mode 100644 .playwright-mcp/page-2026-09-04T04-45-37-881Z.yml create mode 100644 .playwright-mcp/page-2026-09-04T04-46-14-914Z.yml create mode 100644 .playwright-mcp/page-2026-09-04T04-46-19-545Z.yml create mode 100644 .playwright-mcp/page-2026-09-04T04-46-29-422Z.yml create mode 100644 .playwright-mcp/page-2026-09-04T04-46-50-621Z.yml create mode 100644 bff/.dockerignore create mode 100644 bff/Dockerfile create mode 100644 bff/pom.xml create mode 100644 bff/src/main/java/com/example/keycloakpattern/bff/BffApplication.java create mode 100644 bff/src/main/java/com/example/keycloakpattern/bff/BffController.java create mode 100644 bff/src/main/java/com/example/keycloakpattern/bff/CsrfController.java create mode 100644 bff/src/main/java/com/example/keycloakpattern/bff/SecurityConfig.java create mode 100644 bff/src/main/java/com/example/keycloakpattern/bff/SpaCsrfTokenRequestHandler.java create mode 100644 bff/src/main/resources/application.yml create mode 100644 bff/src/main/resources/static/app.js create mode 100644 bff/src/main/resources/static/index.html create mode 100644 bff/src/test/java/com/example/keycloakpattern/bff/BffControllerTest.java create mode 100644 deploy/lab/k8s/bff-redis.yaml create mode 100644 docs/evidence/b0-bff-redis-deploy/01-deploy.txt create mode 100644 docs/evidence/b0-bff-redis-deploy/02-autoconfiguration.txt create mode 100644 docs/evidence/b0-bff-redis-deploy/03-beans-analysis.txt create mode 100644 docs/evidence/b0-bff-redis-deploy/README.md create mode 100644 docs/evidence/b0-bff-redis-deploy/b0-bff-login-success-single-replica.png create mode 100644 docs/evidence/b0-bff-redis-deploy/b0-bff-token-boundary.png create mode 100644 docs/experiment-b0-bff-redis-deploy.md 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 0000000000000000000000000000000000000000..f5faa708956356a4f117b73e98a2966787b5cf4d GIT binary patch literal 34830 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.!A370TAfirhsa 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 E0FR^K9RL6T literal 0 HcmV?d00001 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` 는 **내부 구조를 그대로 드러낸다.** 실험대에서만 연다 |