diff --git a/.playwright-mcp/console-2026-09-04T05-46-37-048Z.log b/.playwright-mcp/console-2026-09-04T05-46-37-048Z.log new file mode 100644 index 0000000..067f8f5 --- /dev/null +++ b/.playwright-mcp/console-2026-09-04T05-46-37-048Z.log @@ -0,0 +1 @@ +[ 882ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/favicon.ico:0 diff --git a/.playwright-mcp/console-2026-09-04T05-48-09-177Z.log b/.playwright-mcp/console-2026-09-04T05-48-09-177Z.log new file mode 100644 index 0000000..87f2be3 --- /dev/null +++ b/.playwright-mcp/console-2026-09-04T05-48-09-177Z.log @@ -0,0 +1 @@ +[ 178ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/favicon.ico:0 diff --git a/.playwright-mcp/page-2026-09-04T05-46-05-665Z.yml b/.playwright-mcp/page-2026-09-04T05-46-05-665Z.yml new file mode 100644 index 0000000..eda8ff5 --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T05-46-05-665Z.yml @@ -0,0 +1,16 @@ +- generic [ref=f45e3]: + - banner [ref=f45e4]: + - generic [ref=f45e5]: keycloak-patterns + - main [ref=f45e6]: + - heading "Sign in to your account" [level=1] [ref=f45e8] + - generic [ref=f45e12]: + - generic [ref=f45e13]: + - generic [ref=f45e14]: Username or email + - textbox "Username or email" [active] [ref=f45e17] + - generic [ref=f45e18]: + - generic [ref=f45e19]: Password + - generic [ref=f45e21]: + - textbox "Password" [ref=f45e24] + - button "Show password" [ref=f45e26] [cursor=pointer]: + - generic [aria-hidden] [ref=f45e27]:  + - button "Sign In" [ref=f45e30] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-04T05-46-18-702Z.yml b/.playwright-mcp/page-2026-09-04T05-46-18-702Z.yml new file mode 100644 index 0000000..112f363 --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T05-46-18-702Z.yml @@ -0,0 +1,7 @@ +- main [ref=f46e2]: + - heading "AP3 · Backend-for-Frontend" [level=1] [ref=f46e3] + - paragraph [ref=f46e4]: 브라우저에는 OAuth token이 전혀 전달되지 않습니다. HttpOnly session cookie로 BFF만 호출하고, BFF가 서버 보관 access token을 Resource Server 요청에 붙입니다. + - button "Keycloak 로그인" [ref=f46e5] [cursor=pointer] + - button "token 경계 확인" [ref=f46e6] [cursor=pointer] + - button "BFF 경유 API 호출" [ref=f46e7] [cursor=pointer] + - button "CSRF token으로 상태 변경" [ref=f46e8] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-04T05-46-37-934Z.yml b/.playwright-mcp/page-2026-09-04T05-46-37-934Z.yml new file mode 100644 index 0000000..04c1bbe --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T05-46-37-934Z.yml @@ -0,0 +1 @@ +- generic [active] [ref=f47e1]: "{ \"headers\" : { \"host\" : [ \"app2.hyeonworks.com\" ], \"user-agent\" : [ \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36\" ], \"accept\" : [ \"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7\" ], \"accept-encoding\" : [ \"gzip, deflate, br, zstd\" ], \"accept-language\" : [ \"en-US,en;q=0.9\" ], \"cookie\" : [ \"grafana_session=60c3e7ae41ffc00665f4a2c377def399; grafana_session_expiry=1788497124; _oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMDJZakF5T0dFM01HWTJPV000WmpCa1lUazVOalpsWWpNMk9UY3laR1ptTWcuWmpUamNTbGxVSHV1RmJjQ2ZRd2lsUQ==|1788500797|17Hbo3RDtzOldnLZx2xOt3e34lHo_v0yqRNdqdKUx-g=\" ], \"priority\" : [ \"u=0, i\" ], \"sec-ch-ua\" : [ \"\\\"Chromium\\\";v=\\\"152\\\", \\\"Not?A_Brand\\\";v=\\\"24\\\", \\\"Google Chrome\\\";v=\\\"152\\\"\" ], \"sec-ch-ua-mobile\" : [ \"?0\" ], \"sec-ch-ua-platform\" : [ \"\\\"Linux\\\"\" ], \"sec-fetch-dest\" : [ \"document\" ], \"sec-fetch-mode\" : [ \"navigate\" ], \"sec-fetch-site\" : [ \"none\" ], \"sec-fetch-user\" : [ \"?1\" ], \"upgrade-insecure-requests\" : [ \"1\" ], \"x-forwarded-email\" : [ \"labuser@example.com\" ], \"x-forwarded-host\" : [ \"app2.hyeonworks.com\" ], \"x-forwarded-port\" : [ \"443\" ], \"x-forwarded-preferred-username\" : [ \"labuser\" ], \"x-forwarded-proto\" : [ \"https\" ], \"x-forwarded-server\" : [ \"traefik-5d6fcf895-wpfhr\" ], \"x-forwarded-user\" : [ \"27df5ea9-8703-4ec5-badd-d972c583e1ff\" ], \"x-real-ip\" : [ \"100.123.124.30\" ] }, \"remoteAddr\" : \"100.123.124.30\", \"localAddr\" : \"10.42.0.53\", \"scheme\" : \"https\", \"secure\" : true, \"serverName\" : \"app2.hyeonworks.com\", \"serverPort\" : 443, \"requestUrl\" : \"https://app2.hyeonworks.com/api/echo\" }" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-04T05-48-04-197Z.yml b/.playwright-mcp/page-2026-09-04T05-48-04-197Z.yml new file mode 100644 index 0000000..a922b12 --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T05-48-04-197Z.yml @@ -0,0 +1 @@ +- generic [active] [ref=f48e1]: "{\"pattern\":\"AP3-backend-for-frontend\",\"principal\":\"labuser\",\"accessTokenStoredOnServer\":true,\"refreshTokenStoredOnServer\":true,\"browserTokenCount\":0,\"csrfProtectionEnabled\":true}" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-04T05-48-09-366Z.yml b/.playwright-mcp/page-2026-09-04T05-48-09-366Z.yml new file mode 100644 index 0000000..33eafbc --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T05-48-09-366Z.yml @@ -0,0 +1 @@ +- generic [active] [ref=f49e1]: "{ \"headers\" : { \"host\" : [ \"app2.hyeonworks.com\" ], \"user-agent\" : [ \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36\" ], \"accept\" : [ \"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7\" ], \"accept-encoding\" : [ \"gzip, deflate, br, zstd\" ], \"accept-language\" : [ \"en-US,en;q=0.9\" ], \"cookie\" : [ \"grafana_session=60c3e7ae41ffc00665f4a2c377def399; grafana_session_expiry=1788497124; _oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMDJZakF5T0dFM01HWTJPV000WmpCa1lUazVOalpsWWpNMk9UY3laR1ptTWcuWmpUamNTbGxVSHV1RmJjQ2ZRd2lsUQ==|1788500797|17Hbo3RDtzOldnLZx2xOt3e34lHo_v0yqRNdqdKUx-g=\" ], \"priority\" : [ \"u=0, i\" ], \"sec-ch-ua\" : [ \"\\\"Chromium\\\";v=\\\"152\\\", \\\"Not?A_Brand\\\";v=\\\"24\\\", \\\"Google Chrome\\\";v=\\\"152\\\"\" ], \"sec-ch-ua-mobile\" : [ \"?0\" ], \"sec-ch-ua-platform\" : [ \"\\\"Linux\\\"\" ], \"sec-fetch-dest\" : [ \"document\" ], \"sec-fetch-mode\" : [ \"navigate\" ], \"sec-fetch-site\" : [ \"none\" ], \"sec-fetch-user\" : [ \"?1\" ], \"upgrade-insecure-requests\" : [ \"1\" ], \"x-forwarded-email\" : [ \"labuser@example.com\" ], \"x-forwarded-host\" : [ \"app2.hyeonworks.com\" ], \"x-forwarded-port\" : [ \"443\" ], \"x-forwarded-preferred-username\" : [ \"labuser\" ], \"x-forwarded-proto\" : [ \"https\" ], \"x-forwarded-server\" : [ \"traefik-5d6fcf895-wpfhr\" ], \"x-forwarded-user\" : [ \"27df5ea9-8703-4ec5-badd-d972c583e1ff\" ], \"x-real-ip\" : [ \"100.123.124.30\" ] }, \"remoteAddr\" : \"100.123.124.30\", \"localAddr\" : \"10.42.0.53\", \"scheme\" : \"https\", \"secure\" : true, \"serverName\" : \"app2.hyeonworks.com\", \"serverPort\" : 443, \"requestUrl\" : \"https://app2.hyeonworks.com/api/echo\" }" \ No newline at end of file diff --git a/docs/evidence/c1-multi-app-sso/01-baseline.txt b/docs/evidence/c1-multi-app-sso/01-baseline.txt new file mode 100644 index 0000000..1831065 --- /dev/null +++ b/docs/evidence/c1-multi-app-sso/01-baseline.txt @@ -0,0 +1,11 @@ +=== 깨끗한 상태로 초기화 === +DELETE 1 + +=== 기준선 === + Keycloak 온라인 세션: 4 + Redis 키: 0 + +=== 두 앱의 구조 === + app1.hyeonworks.com → BFF (서버 세션: Redis + PostgreSQL) + app2.hyeonworks.com → oauth2-proxy (쿠키 티켓 + Redis) + 둘 다 realm keycloak-patterns 를 쓴다 diff --git a/docs/evidence/c1-multi-app-sso/02-after-app1-login.txt b/docs/evidence/c1-multi-app-sso/02-after-app1-login.txt new file mode 100644 index 0000000..e6793d2 --- /dev/null +++ b/docs/evidence/c1-multi-app-sso/02-after-app1-login.txt @@ -0,0 +1,9 @@ +=== app1 로그인 직후 Keycloak 세션 === + user_session_id | client_sessions +--------------------------+----------------- + oqOjHekin4JU-BZjgQLjUByW | 1 +(1 row) + + Redis 키: 1 + bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679 + PostgreSQL authorized client: 1 행 diff --git a/docs/evidence/c1-multi-app-sso/03-after-app2-visit.txt b/docs/evidence/c1-multi-app-sso/03-after-app2-visit.txt new file mode 100644 index 0000000..1fef5ce --- /dev/null +++ b/docs/evidence/c1-multi-app-sso/03-after-app2-visit.txt @@ -0,0 +1,20 @@ +=== app2 방문 후 — 로그인 화면 없이 통과했는가 === + user_session_id | client_sessions +--------------------------+----------------- + oqOjHekin4JU-BZjgQLjUByW | 2 +(1 row) + + +=== 어느 클라이언트가 붙었는가 === + client_id | name +--------------------------------------+------------------ + 9055fa46-6abb-4d6d-a339-8a9183bbf26d | bff-confidential + 80431dbc-af81-4673-9790-ad06d1570b2e | oauth2-proxy +(2 rows) + + +=== 저장소 상태 === + Redis 키: + _oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2 + bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679 + PostgreSQL authorized client: 1 행 diff --git a/docs/evidence/c1-multi-app-sso/04-sso-session-killed.txt b/docs/evidence/c1-multi-app-sso/04-sso-session-killed.txt new file mode 100644 index 0000000..0f3a2cf --- /dev/null +++ b/docs/evidence/c1-multi-app-sso/04-sso-session-killed.txt @@ -0,0 +1,25 @@ +=== ★ Keycloak 의 SSO 세션 하나를 죽인다 === + 남은 Keycloak 세션: 1 + +=== 두 앱의 애플리케이션 세션은 그대로인가 === + _oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2 + bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679 + PostgreSQL authorized client: 1 행 + + → IdP 세션은 없어졌는데 앱 세션은 남아 있다면, 두 계층의 수명이 어긋난 것이다 +=== 사용자 단위 로그아웃 (IdP 세션만 끊는다) === + 남은 Keycloak 세션: 1 + +=== 앱 세션은 남아 있는가 === + _oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2 + bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679 + PostgreSQL authorized client: 1 행 +=== 남은 세션의 realm 과 client === + user_session_id | realm | clients +--------------------------+--------+--------- + E1q5xI7tt4U_WhZpW7rEPIF2 | master | 1 +(1 row) + + +=== 브라우저에서 두 앱을 다시 열면 어떻게 되는가 === + (IdP 세션이 사라졌으면 재로그인이 필요해야 한다) diff --git a/docs/evidence/c1-multi-app-sso/README.md b/docs/evidence/c1-multi-app-sso/README.md new file mode 100644 index 0000000..cdf44cb --- /dev/null +++ b/docs/evidence/c1-multi-app-sso/README.md @@ -0,0 +1,19 @@ +# C-1 — 다중 앱 SSO 증거 + +2026-09-04 16:15–16:30 KST +해설: [`docs/experiment-c1-multi-app-sso.md`](../../experiment-c1-multi-app-sso.md) + +| 파일 | 무엇을 보여주는가 | +|---|---| +| `01-baseline.txt` | 초기화 시도 — `logout-all` 이 안 먹어 세션 4개가 남았다 | +| `02-after-app1-login.txt` | app1 로그인 후 — user session 1 · client session 1 · Redis 1 · DB 1행 | +| `03-after-app2-visit.txt` | **app2 방문 후 client session 1 → 2**, `bff-confidential` 과 `oauth2-proxy` 가 같은 user session 에 붙음. Redis 에 두 종류 세션 | +| `04-sso-session-killed.txt` | IdP 세션 삭제 후 — **앱 세션 셋 다 남아 있다**. realm 을 join 해 보고서야 남은 것이 master 세션임을 확인 | +| `c1-sso-app2-no-login-screen.png` | app2 가 로그인 화면 없이 열린 화면 | +| `c1-apps-alive-after-idp-logout.png` | **IdP 세션을 죽인 뒤에도 그대로 열리는 화면** | + +## 핵심 세 줄 + +1. **SSO 는 user session 1개에 client session N개** 구조다 — A-3(전체 소실)과 B-3(client 만 제거)의 차이가 여기서 의미를 갖는다. +2. **IdP 세션을 죽여도 두 앱은 계속 동작한다.** 세 층(IdP·앱·토큰)의 수명이 각자이기 때문이다. +3. **IdP 는 "로그인 경로"의 단일 장애점이지 "이미 로그인한 사용자"의 단일 장애점이 아니다.** 장애는 앱 세션 수명만큼 지연되어 몰려온다. diff --git a/docs/evidence/c1-multi-app-sso/c1-apps-alive-after-idp-logout.png b/docs/evidence/c1-multi-app-sso/c1-apps-alive-after-idp-logout.png new file mode 100644 index 0000000..3261c1b Binary files /dev/null and b/docs/evidence/c1-multi-app-sso/c1-apps-alive-after-idp-logout.png differ diff --git a/docs/evidence/c1-multi-app-sso/c1-sso-app2-no-login-screen.png b/docs/evidence/c1-multi-app-sso/c1-sso-app2-no-login-screen.png new file mode 100644 index 0000000..3261c1b Binary files /dev/null and b/docs/evidence/c1-multi-app-sso/c1-sso-app2-no-login-screen.png differ diff --git a/docs/experiment-c1-multi-app-sso.md b/docs/experiment-c1-multi-app-sso.md new file mode 100644 index 0000000..d06976e --- /dev/null +++ b/docs/experiment-c1-multi-app-sso.md @@ -0,0 +1,244 @@ +# C-1 — SSO 를 붙이면 무엇이 달라지는가 + +브랜치 `feature/keycloak-c1-multi-app-sso` · +증거 [`docs/evidence/c1-multi-app-sso/`](evidence/c1-multi-app-sso/) · +2026-09-04 16:15–16:30 KST + +선행: [`B-2`](experiment-b2-multi-instance-session.md) (app1 = BFF) · +[`B-7`](experiment-b7-cookie-secret-rotation.md) (app2 = oauth2-proxy) + +**원래 질문** — *"SSO 를 추가하게 되면 어떻게 달라지는지"* + +--- + +## 0. 결론부터 + +| 물음 | 답 | +|---|---| +| 두 번째 앱에서 로그인 화면이 뜨는가 | **안 뜬다.** SSO 가 동작한다 | +| Keycloak 안의 구조는 | **user session 1개 · client session 2개** | +| **IdP 세션을 죽이면 두 앱이 끊기는가** | **★ 안 끊긴다.** 두 앱 모두 그대로 동작한다 | + +**"SSO 를 붙이면 IdP 가 단일 장애점이 된다" 는 절반만 맞다.** +**로그인할 때는 그렇고, 이미 로그인한 뒤에는 아니다.** + +--- + +## 1. 구성 — 서로 다른 구조의 두 앱 + +``` + app1.hyeonworks.com → BFF 서버 세션 (Redis) + 토큰 (PostgreSQL) + app2.hyeonworks.com → oauth2-proxy 쿠키 티켓 + 세션 (Redis) + + 둘 다 realm keycloak-patterns +``` + +**우연히 좋은 실험대가 됐다.** B-2 와 B-7 에서 만든 두 앱이 +**같은 IdP 를 쓰지만 세션을 완전히 다르게 다룬다.** + +### 깨끗한 상태에서 시작한다 + +```bash +kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ + -c "delete from offline_client_session" -c "delete from offline_user_session" +kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall +kubectl -n keycloak-lab rollout restart statefulset/keycloak +``` + +**`kcadm create realms/.../logout-all` 은 듣지 않았다.** 세션이 그대로 남아 +DB 를 직접 지우고 Keycloak 을 재시작해야 했다 — **캐시 때문이다** +(A-1 에서 확인한 대로, DB 를 직접 지워도 캐시는 남는다). + +``` + Keycloak 온라인 세션: 0 + app1 HTTP 200 / app2 HTTP 200 +``` + +--- + +## 2. SSO 가 동작한다 + +### app1 로그인 — 로그인 화면이 나온다 + +``` +https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth + ?client_id=bff-confidential&... +→ Sign in to keycloak-patterns +``` + +로그인 후 + +``` + user_session_id | client_sessions +--------------------------+----------------- + oqOjHekin4JU-BZjgQLjUByW | 1 + + Redis: bff:session:sessions:6e0d9af4-... + PostgreSQL authorized client: 1 행 +``` + +### app2 방문 — **로그인 화면 없이 통과한다** + +![app2 가 로그인 없이 열린다](evidence/c1-multi-app-sso/c1-sso-app2-no-login-screen.png) + +``` + user_session_id | client_sessions +--------------------------+----------------- + oqOjHekin4JU-BZjgQLjUByW | 2 ← 1 → 2 + + client_id | name +--------------------------------------+------------------ + 9055fa46-6abb-4d6d-a339-8a9183bbf26d | bff-confidential + 80431dbc-af81-4673-9790-ad06d1570b2e | oauth2-proxy + + Redis: + bff:session:sessions:6e0d9af4-... ← app1 의 세션 + _oauth2_proxy-6b028a70f... ← app2 의 세션 +``` + +### 개념 — SSO 의 데이터 구조 + +``` + user session (사용자 · 브라우저 하나당 하나) + ├─ client session : bff-confidential + └─ client session : oauth2-proxy +``` + +**A-3 과 B-3 에서 봤던 그 구조가 여기서 의미를 갖는다.** + +- **A-3** — 크래시로 `user_session` 행이 통째로 사라지면 **모든 앱이 끊긴다** +- **B-3** — 재사용 탐지가 **`client_session` 만** 제거하면 **그 앱만 끊긴다** + +**두 층이 나뉘어 있는 이유가 SSO 다.** 앱 하나의 문제가 다른 앱에 번지지 +않도록 하려면 client session 이 따로 있어야 한다. + +--- + +## 3. ★ IdP 세션을 죽여도 두 앱은 살아 있다 + +```bash +kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \ + create users//logout -r keycloak-patterns +``` + +``` + 남은 keycloak-patterns 세션: 0 (master realm 의 admin 세션만 남음) + + Redis: + _oauth2_proxy-6b028a70f... ← 남아 있다 + bff:session:sessions:6e0d9af4-... ← 남아 있다 + PostgreSQL authorized client: 1 행 ← 남아 있다 +``` + +브라우저로 두 앱을 다시 열었다. + +![IdP 세션이 없어도 앱은 동작한다](evidence/c1-multi-app-sso/c1-apps-alive-after-idp-logout.png) + +**둘 다 로그인 화면 없이 그대로 열렸다.** + +### 왜 그런가 — 세 개의 독립된 수명 + +``` + ① IdP 세션 (Keycloak) ssoSessionIdleTimeout 1800초 + ② 앱 세션 (BFF / oauth2-proxy) 각자 30분 / 1시간 + ③ access token 60초 + + ①을 지워도 ②는 자기 수명을 산다 +``` + +**로그아웃은 ①만 지운 것이고, ②는 아무도 안 건드렸다.** + +| 언제 끊기는가 | | +|---|---| +| BFF | access token 이 만료되어 **refresh 를 시도할 때** → `Session not active` | +| oauth2-proxy | 쿠키 만료(1시간) 또는 **토큰 갱신 시도**할 때 | + +**즉시가 아니라 지연되어 끊긴다.** 최대 지연은 access token 수명(60초)이 +아니라 **앱이 다음에 IdP 를 부를 때까지**다. + +> **이것이 B-2 에서 "로그아웃했는데 다시 들어가진다" 를 겪은 것의 반대편이다.** +> 거기서는 앱 세션을 지웠는데 IdP 세션이 남아 재로그인이 됐고, +> 여기서는 IdP 세션을 지웠는데 앱 세션이 남아 계속 들어가진다. +> +> **두 방향 모두 "한쪽만 지우면 다른 쪽이 남는다" 이다.** + +--- + +## 4. 그래서 SSO 의 대가는 무엇인가 + +원래 질문 *"SSO 를 추가하면 어떻게 달라지는가"* 에 대한 답이다. + +| | 앱이 하나일 때 | **SSO 일 때** | +|---|---|---| +| 로그인 | 앱마다 | **한 번** | +| IdP 가 죽으면 | 그 앱만 로그인 불가 | **모든 앱이 로그인 불가** | +| **이미 로그인한 사용자** | — | **★ 영향 없다** (앱 세션이 살아 있으므로) | +| 로그아웃 | 그 앱만 | **전 앱을 끊으려면 백채널 로그아웃이 필요** | +| 세션 수명 | 하나 | **세 층이 각자** — 어긋나면 예측이 어렵다 | + +**IdP 는 "로그인 경로"의 단일 장애점이지 "이미 로그인한 사용자"의 단일 장애점이 +아니다.** A-2(DB 상실)에서 본 것과 합치면 + +``` + Keycloak DB 죽음 → 새 로그인 불가 (전 앱) + → 이미 로그인한 사용자는 앱 세션 수명 동안 계속 쓴다 + → 그 뒤 갱신 시점에 한꺼번에 끊긴다 +``` + +**장애가 즉시 전면화되지 않고 "앱 세션 수명만큼 지연되어 몰려온다."** +이것이 SSO 구조의 장애 모양이며, **모니터링이 어려운 이유**다. + +--- + +## 5. 겪은 문제 + +| 문제 | | +|---|---| +| `kcadm create realms//logout-all` 이 안 먹었다 | 세션 수가 그대로였다. DB 를 직접 지우고 **Keycloak 재시작**해야 했다 (A-1 의 캐시 문제) | +| `kcadm delete sessions/` 도 안 먹었다 | 오류 없이 아무 일도 안 일어났다. `users//logout` 은 동작했다 | +| 세션 수를 셀 때 realm 을 안 봤다 | `master` realm 의 admin 세션이 섞여 "안 지워졌다" 로 오독할 뻔했다 | + +**세 번째가 특히 위험했다** — realm 을 join 해서 보고 나서야 알았다. + +```sql +select us.user_session_id, r.name as realm, ... + from offline_user_session us join realm r on r.id = us.realm_id +``` + +--- + +## 6. 재현 절차 (명령어) + +```bash +# 1. 깨끗한 상태 — logout-all 은 안 먹으므로 DB + 재시작 +kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \ + -c "delete from offline_client_session" -c "delete from offline_user_session" +kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall +kubectl -n keycloak-lab rollout restart statefulset/keycloak + +# 2. app1 로그인 → app2 방문 (로그인 화면이 안 떠야 SSO) + +# 3. 구조 확인 — user session 1 에 client session 2 +kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \ + "select us.user_session_id, r.name as realm, + (select count(*) from offline_client_session cs + where cs.user_session_id=us.user_session_id) as clients + from offline_user_session us join realm r on r.id=us.realm_id + where us.offline_flag='0'" + +# 4. IdP 세션만 죽인다 +kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \ + create users//logout -r keycloak-patterns + +# 5. 두 앱을 다시 연다 — 그대로 열리면 앱 세션이 독립적이라는 뜻 +``` + +--- + +## 7. 다음 실험에 남기는 것 + +| 실험 | 이 실험이 준 것 | +|---|---| +| **C-2** 백채널 로그아웃 | **이 실험이 C-2 가 왜 필요한지 보여준다** — IdP 로그아웃이 앱에 전파되지 않는다 | +| **D-1** 백업·복구 | user session 을 잃으면 전 앱이 끊긴다 — 백업 범위에 들어간다 | +| 운영 | **세 층의 수명을 맞추거나, 어긋날 때의 동작을 정의해야 한다** |