# C-2 — 로그아웃이 전 앱에 퍼지는가 (백채널 로그아웃) 브랜치 `feature/keycloak-c2-backchannel-logout` · 증거 [`docs/evidence/c2-backchannel-logout/`](evidence/c2-backchannel-logout/) · 2026-09-04 16:30–16:55 KST 선행: [`C-1`](experiment-c1-multi-app-sso.md) — **IdP 로그아웃이 앱에 전파되지 않는다**를 관측했다 --- ## 0. 결론부터 | 확인 | 결과 | |---|---| | 백채널 로그아웃이 설정되어 있었는가 | **아니다.** 두 클라이언트 모두 `backchannelLogoutUrl` 없음 | | BFF 에 그 엔드포인트가 있는가 | **아니다.** 소스에 `oidcLogout` 설정이 없다 | | IdP 쪽만 설정하면 되는가 | **★ 안 된다.** 앱 세션이 그대로 남았다 | | Keycloak 이 앱 URL 에 닿기는 하는가 | **닿는다** (`HTTP 200`) — 네트워크 문제가 아니다 | **C-1 이 관측한 "로그아웃이 안 퍼진다" 의 원인은 단순했다 — 아무도 구현하지 않았다.** --- ## 1. 현재 상태 — 어느 쪽에도 없다 ### IdP 쪽 ```bash kcadm.sh get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes ``` ``` frontchannelLogout : false (backchannel.logout.url 없음) ``` `oauth2-proxy` 클라이언트도 마찬가지였다. ### 앱 쪽 ```bash grep -rn "oidcLogout\|backchannel" bff/src/main/java/ ``` **아무것도 안 나온다.** Spring Security 6.2+ 는 백채널 로그아웃을 지원하지만 **명시적으로 켜야 한다.** ```java .oidcLogout(oidc -> oidc.backChannel(Customizer.withDefaults())) ``` 이 설정이 없으면 `/logout/connect/back-channel/{registrationId}` 경로가 **생기지 않는다.** ``` /logout/connect/back-channel/keycloak HTTP 302 ← 로그인으로 리다이렉트 /backchannel-logout HTTP 302 /oauth2/sign_out HTTP 302 ``` **302 는 "그런 핸들러가 없어서 인증 요구로 떨어졌다"는 뜻**이다. 엔드포인트가 있었다면 POST 를 받아 200 이나 400 을 돌려줬을 것이다. --- ## 2. IdP 쪽만 설정하고 시험했다 ```bash kcadm.sh update clients/ -r keycloak-patterns \ -s 'attributes={"backchannel.logout.url":"https://app1.hyeonworks.com/logout/connect/back-channel/keycloak", "backchannel.logout.session.required":"true"}' ``` ``` backchannel.logout.session.required = true backchannel.logout.url = https://app1.hyeonworks.com/logout/connect/back-channel/keycloak ``` ### 살아 있는 세션에 로그아웃을 걸었다 ``` === 로그아웃 전 === keycloak-patterns 세션: 1 Redis: 1 키 === IdP 로그아웃 === kcadm.sh create users//logout -r keycloak-patterns === 결과 === keycloak-patterns 세션: 0 ← IdP 쪽은 끊겼다 Redis: 1 키 ← ★ 앱 세션은 그대로다 ``` **IdP 세션만 사라지고 앱 세션은 남았다.** C-1 과 같은 결과다. ### 네트워크 문제가 아님을 확인했다 ``` === Keycloak 파드가 app1.hyeonworks.com 에 닿는가 === DNS 해석: Address: 100.83.212.4 HTTPS 도달: HTTP 200 ``` **클러스터 안에서 공개 이름으로 앱에 닿는다.** 이 실험대는 tailnet + split DNS 구성이라 헤어핀이 되는데, **운영에서는 안 되는 경우가 흔하다.** > **백채널 로그아웃의 숨은 전제** — IdP 가 **앱의 공개 URL 로 서버에서 서버로** > 요청을 보낼 수 있어야 한다. 앱이 사설망에 있고 IdP 가 밖에 있으면 > **설정을 해도 도달하지 못한다.** 그때는 로그도 안 남고 조용히 실패한다. --- ## 3. 그래서 왜 안 퍼졌는가 ``` IdP 로그아웃 ├─ ① Keycloak 이 backchannel.logout.url 로 POST 를 보낸다 (설정함) ├─ ② 앱이 그 POST 를 받는 엔드포인트를 갖고 있다 ★ 없다 └─ ③ 앱이 logout token 을 검증하고 sid 로 세션을 찾아 지운다 ★ 없다 ``` **②와 ③이 없다.** ①만 설정해도 받을 사람이 없다. Keycloak 로그에 `backchannel` 문자열이 **0줄**이었다 — 다만 이것만으로 "보내지 않았다"고 단정할 수는 없다 (DEBUG 레벨일 수 있다). **확실한 것은 앱 세션이 남았다는 관측**이다. --- ## 4. 개념 — 백채널 로그아웃의 구조 ``` 사용자가 어느 앱에서든 로그아웃 │ ▼ Keycloak 이 SSO 세션에 붙은 client session 목록을 본다 (C-1 의 그 구조) │ ├──POST──▶ app1 의 backchannel.logout.url └──POST──▶ app2 의 backchannel.logout.url 본문: logout_token (JWT) { "sid": "...", "sub": "...", "events": {...} } ``` ### `sid` 가 여기서 쓰인다 **A-0 에서 확인한 그 `sid`** 다 — JWT·DB·관리 API 에서 같은 문자열이었던. ``` logout_token 의 sid → 앱이 "그 sid 로 만든 내 세션"을 찾아 지운다 ``` **그래서 앱은 `sid → 자기 세션 ID` 역인덱스를 갖고 있어야 한다.** Spring Security 는 이를 위해 `OidcSessionRegistry` 를 쓰며, **여러 인스턴스가 있으면 그 레지스트리도 공유 저장소여야 한다** — B-1·B-2 에서 겪은 것과 **같은 문제가 한 겹 더 있다.** ### 부분 실패는 어떻게 되는가 ``` app1 로그아웃 성공, app2 는 응답 없음 └─ Keycloak 은 재시도하는가? 얼마나? └─ 사용자는 app2 에서 여전히 로그인 상태다 ``` **로그아웃은 원자적이지 않다.** 앱이 늘어날수록 "일부만 로그아웃된 상태"가 생길 확률이 올라간다. --- ## 5. 겪은 문제 | 문제 | | |---|---| | `kcadm -s "attributes.backchannel.logout.url=..."` 이 exit 1 | 점 표기가 안 먹는다. **JSON 으로 통째로** 줘야 한다 | | 첫 시험이 무의미했다 | 로그아웃 전 IdP 세션이 **이미 0** 이었다. 끊을 대상이 없었다 | | 세션 수를 realm 없이 셌다 | C-1 과 같은 실수 — `master` 의 admin 세션이 섞인다 | | Keycloak 재시작 후 로그인 폼이 안 넘어갔다 | 인증 세션 쿠키가 무효화된 상태에서 폼을 재사용했다 | **두 번째가 A층에서 반복한 교훈이다** — **주입 대상이 실제로 존재하는지 먼저 확인한다.** 세션이 없는 상태에서 로그아웃을 걸고 "전파가 안 된다"고 결론지을 뻔했다. --- ## 6. 구현하려면 무엇이 필요한가 | 계층 | 할 일 | |---|---| | **IdP** | 클라이언트마다 `backchannel.logout.url` 설정 (완료) | | **앱** | `.oidcLogout(oidc -> oidc.backChannel(...))` 활성화 | | **앱** | `OidcSessionRegistry` 를 **공유 저장소**로 (인스턴스가 여럿이므로) | | **네트워크** | IdP → 앱 공개 URL 도달 (이 실험대는 됨, 운영은 확인 필요) | | **oauth2-proxy** | **지원하지 않는다.** 별도 방안이 필요하다 | **마지막이 C-1 과 맞물린다** — app1(BFF)은 구현할 수 있지만 app2(oauth2-proxy)는 못 한다. **한 SSO 안에서 로그아웃 전파가 앱마다 다르게 동작하게 된다.** --- ## 7. 재현 절차 (명령어) ```bash # 1. 현재 설정 확인 — 어느 쪽에도 없다 kcadm.sh get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes grep -rn "oidcLogout\|backchannel" bff/src/main/java/ curl -s -o /dev/null -w '%{http_code}\n' -X POST https://app1.hyeonworks.com/logout/connect/back-channel/keycloak # 2. IdP 쪽 설정 — 점 표기는 안 먹는다. JSON 으로 kcadm.sh update clients/ -r keycloak-patterns \ -s 'attributes={"backchannel.logout.url":"...","backchannel.logout.session.required":"true"}' # 3. ★ 살아 있는 세션이 있는지 먼저 확인한다 (realm 을 join 해서) kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \ "select count(*) from offline_user_session us join realm r on r.id=us.realm_id where r.name='keycloak-patterns' and us.offline_flag='0'" # 4. 로그아웃하고 앱 세션을 본다 kcadm.sh create users//logout -r keycloak-patterns kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize # 5. 도달성 확인 — 클러스터 안에서 앱 공개 URL 로 kubectl -n keycloak-lab run t --rm -i --restart=Never --image=curlimages/curl:8.11.1 \ --command -- curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/ ``` --- ## 8. 다음에 남기는 것 | | | |---|---| | **구현** | `.oidcLogout()` 활성화 + `OidcSessionRegistry` 공유 | | **oauth2-proxy** | 백채널 로그아웃 미지원 — SSO 안에서 앱마다 동작이 갈린다 | | **운영** | IdP → 앱 도달성이 전제다. 안 되면 **조용히 실패한다** | | **B-2 와 연결** | 로그아웃이 지우는 것은 지금도 세 곳 중 하나뿐이다 |