An independent audit found ten documents printing values their evidence files do not contain. C-1 printed a session count of 0 where the evidence says 4, C-2 printed a success readback for a command that exited 1, and A-1 credited the conntrack flush with a split that the timestamps attribute to a pod restart four seconds earlier. Also measured wal_writer_delay, which A-3 had asserted as matching without ever querying it, relabelled the A-6 control that moved 41 percent, noted A-8's nine-sample resolution, corrected D-1's RTO to the 41 seconds its own timeline shows, and added a correction banner to D-2. Every experiment document now links its evidence files with their real collection times, and the duplicate screenshots are documented as duplicates. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
262 lines
9.9 KiB
Markdown
262 lines
9.9 KiB
Markdown
# 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/<id> -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
|
||
```
|
||
|
||
> **출처 주의** — 위 확인 출력은
|
||
> [`02-configure-idp.txt`](evidence/c2-backchannel-logout/02-configure-idp.txt) 가
|
||
> 아니라 그 뒤 별도로 실행한 조회에서 나온 것이다. 그 파일에는
|
||
> **`command terminated with exit code 1`** 이 남아 있다 —
|
||
> `-s "attributes.backchannel.logout.url=..."` 의 점 표기가 실패한 첫 시도이며,
|
||
> JSON 으로 다시 넣어 성공했다. **실패한 시도의 파일에 성공 출력을 붙여
|
||
> 인쇄한 것은 잘못이었다.**
|
||
|
||
### 살아 있는 세션에 로그아웃을 걸었다
|
||
|
||
```
|
||
=== 로그아웃 전 ===
|
||
keycloak-patterns 세션: 1
|
||
Redis: 1 키
|
||
|
||
=== IdP 로그아웃 ===
|
||
kcadm.sh create users/<id>/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 안에서 로그아웃 전파가 앱마다
|
||
다르게 동작하게 된다.**
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## 증거 파일
|
||
|
||
**증거 수집 시각: 2026-09-04 14:50 – 14:53 KST** (파일 mtime 기준. 문서 상단의 시각 표기는 작성 시점이라 다를 수 있다.)
|
||
|
||
| 파일 | 종류 |
|
||
|---|---|
|
||
| [`01-current-state.txt`](evidence/c2-backchannel-logout/01-current-state.txt) | 터미널 원문 |
|
||
| [`02-configure-idp.txt`](evidence/c2-backchannel-logout/02-configure-idp.txt) | 터미널 원문 |
|
||
| [`03-logout-attempt.txt`](evidence/c2-backchannel-logout/03-logout-attempt.txt) | 터미널 원문 |
|
||
| [`04-reachability.txt`](evidence/c2-backchannel-logout/04-reachability.txt) | 터미널 원문 |
|
||
|
||
파일별 상세는 [`evidence/c2-backchannel-logout/README.md`](evidence/c2-backchannel-logout/README.md).
|
||
|
||
## 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/<id> -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/<user-id>/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 와 연결** | 로그아웃이 지우는 것은 지금도 세 곳 중 하나뿐이다 |
|