Files
keycloak-pattern/docs/guides/experiments/c2-backchannel-logout.md
T
DongHyeonkaandClaude Opus 5 6f6ab86345 docs(guides): reproduction guides for all 26 experiments
Written by subagents running under the writing-practitioner-guides skill,
one guide per experiment, 22,566 lines. Each walks a reader from baseline
capture through injection, injection verification, observation and recovery.

Section 3 carries the weight in most of them. Injection failed silently nine
times in this lab, and a failed injection looks exactly like no effect — so
the guides verify the target is actually in the intended state before
reading any result. A-4 makes virsh list the only proof because the node
reads Ready for 40 seconds after the machine is off; A-5 makes the packet
counter the sole go/no-go because a rule on the wrong node produces an empty
result that reads like a finding; A-6 quotes the run where 적용완료 was
printed between four Cannot find device "eth0" lines.

The traps the guides are built around are ones that invert a conclusion
rather than merely annoy:

  A-0   emptying the session table without a restart leaves cache entries
        that get counted as replication arriving
  A-2   dropping -o /dev/null fuses body and status into one string
  A-3   presence of "ready to accept connections" instead of its timestamp
  B-2   row count alone reads an UPDATE as nothing having happened
  B-4   tr ',' '\n' splits ["admin","editor"] so only admin is seen
  B-7   no login screen means the cookie died and SSO re-authenticated
  C-1   counting sessions without joining realm counts your own kcadm one
  D-1   kubectl exec without -i restores nothing and still exits 0
  D-4a  "ran with error output" is what success looks like

Every quoted block is copied from docs/evidence/ and marked 실측; reshaped
commands are marked 미검증 rather than passed off as measured. Where a source
document carries a ★ correction the guides follow the corrected claim — A-7's
REVOKED_TOKEN hypothesis, C-1's session count, B-2's schema attribution.

Two hazards are stated rather than smoothed over: B-6 deletes a key that
cannot be recreated, and D-1/D-4 need host sudo, which asks for a password,
so those steps say a person must type them.

Audit over all 26: 672 interpretation pairs, 486 evidence citations, 117
undo sections, and zero occurrences of the patterns the skill forbids —
no python data processing, no deprecated kubectl get endpoints, no
placeholders, no bare kcadm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:29:00 +09:00

735 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# C-2 재현 가이드 — 로그아웃이 왜 안 퍼지는지 양쪽에서 확인한다
해설 문서: [`docs/experiment-c2-backchannel-logout.md`](../../experiment-c2-backchannel-logout.md) ·
증거 원문: [`docs/evidence/c2-backchannel-logout/`](../../evidence/c2-backchannel-logout/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 두 클라이언트 어디에도 `backchannel.logout.url` 이 없는 것 | `kcadm get clients` |
| BFF 소스에 `oidcLogout` 이 한 줄도 없는 것 | `grep -rn` |
| 후보 경로 셋이 전부 `302` 인 것 — **핸들러가 없다는 뜻** | `curl` |
| **IdP 쪽만 설정해도 앱 세션이 그대로 남는 것** | Redis |
| Keycloak 이 앱 공개 URL 에 `200` 으로 **닿는** 것 | 임시 curl 파드 |
| 로그에 `backchannel` 이 **0줄**인 것, 그리고 그것으로 단정하면 안 되는 이유 | Keycloak 로그 |
| **끊을 세션이 없는 상태로 시험해 무의미해지는 것** | 로그아웃 전 세션 수 |
## 전제
- [`C-1`](c1-multi-app-sso.md) 이 끝나 있다. app1(BFF)·app2(oauth2-proxy)가
둘 다 살아 있고, **IdP 로그아웃이 앱에 전파되지 않는다**를 이미 관측했다.
이 실험은 **그 원인을 찾는** 실험이다.
- `app2.hyeonworks.com` 은 **Grafana 에서 빌린 이름**이다. 끝나면 되돌린다 —
[5-3](#5-3-빌린-것을-돌려준다).
- **BFF 소스 트리**(`bff/src/main/java/`)를 볼 수 있어야 한다. 1-2 가 그것을 읽는다.
- **브라우저가 필요하다.** 살아 있는 세션을 만들어야 시험이 성립한다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
- **Keycloak 이미지에는 `curl``wget` 도 없다**(`exit 127`). `kcadm.sh`
파드 안에 있으므로 **항상 `kubectl exec` 로 감싼다.** 도달성 시험은
**임시 curl 파드**로 한다.
- 이 실험대에는 **`jq` 가 없다.**
## 주의 — 이건 클라이언트 설정을 바꾸는 실험이다
`bff-confidential` 클라이언트의 **`attributes` 를 통째로 교체한다.**
JSON 으로 주는 방식이라 **기존 속성이 같이 날아갈 수 있다.**
그래서 [2-1](#2-1-지금-attributes-를-먼저-저장해-둔다) 의 첫 명령이 백업이다.
세션도 지운다. 실험대에서만 한다. 전 구간 약 20분.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 14:5014:53 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 값은 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행 기록에 이 명령의 출력은 없다 |
> **★ 출처 하나에 주의가 붙어 있다.** 해설 문서 2절이 인쇄한 「설정이 들어갔다」
> 확인 출력은 [`02-configure-idp.txt`](../../evidence/c2-backchannel-logout/02-configure-idp.txt)
> 에서 나온 것이 **아니다.** 그 파일에는 **`command terminated with exit code 1`**
> 이 남아 있다 — 점 표기로 시도한 **실패한 첫 시도**다. 성공 출력은 그 뒤 별도로
> 실행한 조회에서 나왔다. **실패한 시도의 파일에 성공 출력을 붙여 인쇄한 것은
> 잘못이었고**, 이 가이드는 3-1 에서 그 둘을 갈라 적는다.
클라이언트 UUID·IP 는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
---
# 0. 왜 이 실험을 하는가
[C-1](c1-multi-app-sso.md) 이 이것을 관측했다.
```
IdP 세션을 죽였다 → 앱 세션은 그대로 → 두 앱이 계속 열린다
```
**왜 안 퍼졌는지는 안 물었다.** 후보가 셋 있다.
| 후보 | 판정하는 법 |
|---|---|
| ① IdP 에 **보낼 주소**가 설정되어 있지 않다 | 클라이언트 속성을 본다 |
| ② 앱에 **받을 엔드포인트**가 없다 | 소스와 실제 경로를 본다 |
| ③ IdP 가 앱에 **못 닿는다** (네트워크) | 클러스터 안에서 앱 URL 을 쳐 본다 |
| | |
|---|---|
| 예상 | 셋 중 하나가 원인일 것 |
| **실측** | **①과 ②가 둘 다 없었다.** ③은 문제가 아니었다(`HTTP 200`) |
**C-1 이 관측한 「로그아웃이 안 퍼진다」의 원인은 단순했다 — 아무도
구현하지 않았다.**
그리고 이 실험이 실제로 증명하는 것은 그 다음이다.
```
①만 고친다 → 여전히 안 퍼진다
```
**양쪽이 다 있어야 동작한다.** 한쪽만 고치고 「설정했으니 되겠지」로 넘어가는
것이 이 주제에서 가장 흔한 실패다. 이 가이드는 **그 실패를 일부러 재현한다.**
---
# 1. 기준선 — 어느 쪽에도 없다
넓은 것부터 좁혀 간다.
```
IdP 설정 → 앱 소스 → 앱의 실제 경로 → ★ 끊을 세션이 있기는 한가
```
## 1-1. IdP 쪽 — 클라이언트 속성을 본다
**kcadm 을 먼저 로그인시킨다.**
**하기**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
config credentials --server http://localhost:8080 --realm master --user admin \
--password "$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
```
**확인** — 두 클라이언트를 각각 본다
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get clients -r keycloak-patterns -q clientId=oauth2-proxy --fields attributes
```
**실측** — [`01-current-state.txt`](../../evidence/c2-backchannel-logout/01-current-state.txt)
```
=== 현재 클라이언트의 백채널 로그아웃 설정 ===
--- bff-confidential ---
"frontchannelLogout" : false,
--- oauth2-proxy ---
"frontchannelLogout" : false,
```
**어디를 봐야 하는가****있는 것이 아니라 없는 것을 본다.**
`backchannel.logout.url` 이 목록에 **없다.** 나온 것은 `frontchannelLogout` 뿐이다.
> **「없다」를 확인하는 법.** `grep backchannel` 로 걸러서 빈 출력을 보면
> 「없다」인지 「명령이 안 먹었다」인지 구별되지 않는다 —
> B-6 에서 `kcadm get components -q type=…` 이 정확히 그렇게 조용히 실패했다.
> **`--fields attributes` 로 통째로 받아 눈으로 훑는다.**
> 다른 값(`frontchannelLogout`)이 보이는 것이 「명령은 먹었다」의 증거다.
## 1-2. 앱 쪽 — 소스에 받을 자리가 있는가
**확인**
```bash
grep -rn "oidcLogout\|backchannel" bff/src/main/java/
```
**실측** — [`01-current-state.txt`](../../evidence/c2-backchannel-logout/01-current-state.txt)
```
=== BFF 가 백채널 로그아웃 엔드포인트를 갖고 있는가 ===
```
**어디를 봐야 하는가****아무것도 안 나온다.** 헤더 아래가 비어 있다.
**이 결과가 의미하는 것** — Spring Security 6.2+ 는 백채널 로그아웃을
**지원하지만 명시적으로 켜야 한다.**
```java
.oidcLogout(oidc -> oidc.backChannel(Customizer.withDefaults()))
```
이 설정이 없으면 `/logout/connect/back-channel/{registrationId}` 경로가
**생기지 않는다.** 소스에 없으니 경로도 없다.
> `grep` 이 빈 출력을 줄 때는 **경로가 맞는지 먼저 의심한다.**
> `ls bff/src/main/java/` 로 디렉터리가 실재하는지 본다. 없는 디렉터리를
> 뒤져도 `grep` 은 조용히 0건을 준다.
## 1-3. 소스 말고 **실제로** 그 경로가 있는지 친다
**소스에 없다는 것과 배포된 앱에 없다는 것은 다른 주장이다.** 직접 친다.
**확인** — 먼저 응답을 통째로 한 번 본다
```bash
curl -s -i -X POST https://app1.hyeonworks.com/logout/connect/back-channel/keycloak | head -12
```
**어디를 봐야 하는가** — 상태줄과 `Location` 헤더. **302 라면 어디로 보내는가.**
로그인 페이지로 보내면 「인증이 필요한 요청으로 처리됐다」는 뜻이고,
**그런 핸들러가 없어서 기본 규칙에 걸린 것**이다.
이제 후보 셋을 나란히 잰다.
**확인**
```bash
for P in /logout/connect/back-channel/keycloak /backchannel-logout /oauth2/sign_out; do
curl -s -o /dev/null -w "$P %{http_code}\n" -X POST "https://app1.hyeonworks.com$P"
done
```
**실측** — [`01-current-state.txt`](../../evidence/c2-backchannel-logout/01-current-state.txt)
```
=== 실제로 그 경로가 있는가 ===
/logout/connect/back-channel/keycloak HTTP 302
/backchannel-logout HTTP 302
/oauth2/sign_out HTTP 302
```
**어디를 봐야 하는가****셋 다 302.**
| 응답 | 뜻 |
|---|---|
| `302` | **그런 핸들러가 없어서 인증 요구로 떨어졌다** |
| `200` / `400` | 엔드포인트가 있고 logout token 을 읽으려 했다 |
| `404` | 라우팅 자체가 없다 |
**이 결과가 의미하는 것** — 302 는 **「없다」의 증거**다. 엔드포인트가 있었다면
POST 본문(logout token)을 읽고 200 이나 400 을 돌려줬을 것이다.
**후보 ②가 확정됐다.** ①은 1-1 에서 확정됐다.
## 1-4. ★ 끊을 세션이 있기는 한가 — 이걸 안 보면 실험이 무의미해진다
**원래 실행이 여기서 한 번 헛돌았다.**
**확인** — C-1 에서 배운 대로 **realm 을 조인해서** 센다
```bash
sudo 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'"
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
```
**실측** — [`03-logout-attempt.txt`](../../evidence/c2-backchannel-logout/03-logout-attempt.txt)
```
=== 로그아웃 전 상태 ===
Redis: 2 키
keycloak-patterns 세션: 0
```
**어디를 봐야 하는가****IdP 세션이 0 이다.** Redis 에는 키가 2개 있는데
Keycloak 쪽은 비어 있다.
**이 결과가 의미하는 것****이 상태에서 로그아웃을 걸면 아무 일도 안 난다.**
끊을 대상이 없기 때문이다. 그리고 「앱 세션이 그대로다」를 보고
**「전파가 안 되는구나」로 결론지을 뻔했다.**
> **★ 이것이 이 실험에서 가장 빠지기 쉬운 함정이다.** 주입은 정상적으로
> 실행되고, 출력도 그럴듯하고, 결론도 원하던 방향이다. **틀린 것은 전제뿐이다.**
> A층 내내 반복한 교훈 — **주입 대상이 실제로 존재하는지 먼저 확인한다.**
**하기** — 그러니 세션을 만든다. 브라우저에서
```
https://app1.hyeonworks.com/ → labuser / labpass
```
**확인** — 다시 센다
```bash
sudo 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'"
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
```
**실측** — [`03-logout-attempt.txt`](../../evidence/c2-backchannel-logout/03-logout-attempt.txt) 의 두 번째 시험
```
=== 로그아웃 전 — 실제 세션이 있는가 ===
keycloak-patterns 세션: 1
Redis: 1 키
```
**어디를 봐야 하는가****세션 수가 1 이상.** 여기서 0 이면 로그인이 안 된 것이다.
**0 인 채로 2절로 넘어가지 않는다.**
---
# 2. 주입 — IdP 쪽에만 설정한다
**의도적으로 한쪽만 고친다.** 「①만 있으면 되는가」가 이 실험의 질문이다.
## 2-1. 지금 attributes 를 먼저 저장해 둔다
**되돌리기가 이 백업에 달렸다.** JSON 으로 통째로 넣는 방식이라 기존 속성이
덮인다.
**하기**
```bash
CID=$(sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get clients -r keycloak-patterns -q clientId=bff-confidential --fields id \
--format csv --noquotes | tail -1)
echo "$CID"
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get "clients/$CID" -r keycloak-patterns --fields attributes \
| tee ~/c2-bff-attributes-backup.json
```
**실측** — [`02-configure-idp.txt`](../../evidence/c2-backchannel-logout/02-configure-idp.txt)
```
=== IdP 쪽에만 백채널 로그아웃 URL 을 설정한다 ===
client id: 9055fa46-6abb-4d6d-a339-8a9183bbf26d
```
**어디를 봐야 하는가**`echo "$CID"` 가 **UUID 한 줄**인가.
비었으면 `--format csv --noquotes | tail -1` 가 다른 것을 잡은 것이고,
그 상태로 다음 명령을 치면 **엉뚱한 클라이언트를 고친다.**
> 이 UUID 는 [C-1 3-2](c1-multi-app-sso.md#3-2-어느-클라이언트가-붙었는가) 에서
> `bff-confidential` 로 확인한 바로 그 값이다(`9055fa46-…`).
> **당신 환경의 값은 다르다. 위 명령이 뽑아 준다.**
## 2-2. ★ 점 표기는 안 먹는다
**하기** — 원래 실행이 처음에 친 것. **실패한다**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update "clients/$CID" -r keycloak-patterns \
-s "attributes.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
```
**어디를 봐야 하는가****종료코드 1.** 이건 조용한 실패가 **아니다**
실패했다고 말해 준다. 다만 `kubectl exec` 를 거치면서 오류 본문이 잘려
**「왜」는 안 보인다.**
**왜 안 되나** — 속성 이름 자체에 점이 들어 있어서(`backchannel.logout.url`)
`kcadm` 의 점 표기와 충돌한다. **JSON 으로 통째로 준다.**
## 2-3. JSON 으로 넣는다
**하기**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update "clients/$CID" -r keycloak-patterns \
-s 'attributes={"backchannel.logout.url":"https://app1.hyeonworks.com/logout/connect/back-channel/keycloak",
"backchannel.logout.session.required":"true"}'
```
**되돌리기** — 2-1 의 백업을 보고 원래 값으로 다시 `update` 한다.
백업이 `{ }` 처럼 비어 있었다면
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update "clients/$CID" -r keycloak-patterns -s 'attributes={}'
```
---
# 3. 주입이 걸렸는지 확인한다
## 3-1. 설정이 실제로 들어갔는가
**확인** — 1-1 과 **똑같은 명령**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get clients -r keycloak-patterns -q clientId=bff-confidential --fields attributes
```
**실측** — 해설 문서 2절이 인쇄한 값. **위 「출처 주의」가 붙는 자리다**
```
backchannel.logout.session.required = true
backchannel.logout.url = https://app1.hyeonworks.com/logout/connect/back-channel/keycloak
```
**어디를 봐야 하는가** — 두 속성이 **둘 다** 있는가.
`url` 만 있고 `session.required` 가 없으면 logout token 에 `sid` 가 안 실린다.
> **이 출력은 [`02-configure-idp.txt`](../../evidence/c2-backchannel-logout/02-configure-idp.txt)
> 에 없다.** 그 파일은 2-2 의 실패로 끝나고, 위 값은 **그 뒤 별도로 실행한
> 조회**에서 나왔다. 증거 파일과 인쇄된 값이 1:1 이 아닌 유일한 자리이므로
> **당신은 지금 직접 재 두는 편이 낫다.**
## 3-2. 이 시점의 앱 상태를 적어 둔다
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
```
**실측** — [`02-configure-idp.txt`](../../evidence/c2-backchannel-logout/02-configure-idp.txt)
```
=== 로그인 상태를 만든다 ===
(브라우저에 이미 세션이 있다)
Keycloak 세션: 2
Redis: 2 키
```
**어디를 봐야 하는가****키 이름을 그대로 적어 둔다.** 4-3 에서 **글자 하나까지
같은지**를 볼 것이다. 개수만 세면 「지워지고 새로 생겼다」와 구별이 안 된다.
---
# 4. 관찰 — 로그아웃을 걸고 앱 세션을 본다
## 4-1. 시각을 적고 로그아웃한다
**하기**
```bash
date '+%H:%M:%S 로그아웃'
USERID=$(sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get users -r keycloak-patterns -q username=labuser --fields id \
--format csv --noquotes | tail -1)
echo "$USERID"
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
create "users/$USERID/logout" -r keycloak-patterns
```
**실측** — [`03-logout-attempt.txt`](../../evidence/c2-backchannel-logout/03-logout-attempt.txt)
```
=== ★ IdP 로그아웃 → 백채널 알림 ===
시각: 14:54:21
```
**시각이 필요한 이유** — 뒤에서 로그를 뒤질 때 **「이 순간 전후」로 좁히기
위해서**다. `--since` 만으로는 어느 시도인지 안 갈린다.
## 4-2. IdP 쪽은 끊겼는가
**확인** — 1-4 와 같은 쿼리
```bash
sudo 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'"
```
**실측** — [`04-reachability.txt`](../../evidence/c2-backchannel-logout/04-reachability.txt)
```
=== IdP 세션은 실제로 끊겼는가 ===
keycloak-patterns 세션: 0
```
**어디를 봐야 하는가****0.** 로그아웃 자체는 동작했다.
**이 결과가 의미하는 것****주입은 성공했다.** 이제 앱 쪽을 볼 자격이 생겼다.
여기가 1 이면 로그아웃이 실패한 것이고, 앱 세션이 남아 있어도 그건 당연한
결과라 아무것도 판정하지 못한다.
## 4-3. ★ 앱 세션은 그대로다
**확인** — 3-2 와 **똑같은 명령**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
```
**실측** — [`03-logout-attempt.txt`](../../evidence/c2-backchannel-logout/03-logout-attempt.txt)
```
=== 앱 세션이 정리되었는가 ===
Redis: 2 키
_oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
```
그리고 두 번째 시험(세션이 실제로 1개 있던 판)에서도
```
=== 앱 세션 ===
Redis: 1 키
```
**어디를 봐야 하는가****개수도 이름도 그대로다.**
**이 결과가 의미하는 것****IdP 쪽만 설정해도 소용없다.**
①(보낼 주소)은 넣었는데 아무 일도 안 일어났다. C-1 과 정확히 같은 결과다.
## 4-4. 로그에 흔적이 있는가 — 그리고 그것으로 무엇을 말할 수 있는가
**확인** — Keycloak 양쪽 노드
```bash
sudo kubectl -n keycloak-lab logs keycloak-0 | grep -ci backchannel
sudo kubectl -n keycloak-lab logs keycloak-1 | grep -ci backchannel
```
**실측** — [`04-reachability.txt`](../../evidence/c2-backchannel-logout/04-reachability.txt)
```
=== Keycloak 로그 전체에서 backchannel 흔적 ===
keycloak-0: 0 줄
keycloak-1: 0 줄
```
**확인** — 앱 쪽
```bash
sudo kubectl -n keycloak-lab logs -l app=bff --since=5m --prefix | grep -i 'back-channel\|logout'
```
**실측** — [`03-logout-attempt.txt`](../../evidence/c2-backchannel-logout/03-logout-attempt.txt)
```
=== BFF 로그 — 백채널 요청이 도착했는가 ===
```
**어디를 봐야 하는가** — 양쪽 다 비어 있다.
**★ 그런데 여기서 결론을 넓히면 안 된다.**
| 이 출력이 말하는 것 | 말하지 않는 것 |
|---|---|
| 로그에 `backchannel` 문자열이 없다 | **Keycloak 이 요청을 안 보냈다** |
| BFF 로그에 도착 흔적이 없다 | 요청이 아예 안 왔다 |
**로그 레벨이 DEBUG 였다면 안 찍혔을 수 있다.** 「0줄」은 「안 보냈다」의
증거가 아니라 **「기본 로그 레벨에서는 안 보인다」**일 뿐이다.
> **확실한 것은 앱 세션이 남았다는 관측이다.** 그것은 직접 봤다(4-3).
> **관측한 것과 추론한 것을 섞지 않는다.** 로그 0줄을 근거로 「Keycloak 이
> 안 보냈다」고 쓰면, 나중에 DEBUG 를 켜서 보냈다는 게 밝혀졌을 때
> 결론 전체의 신뢰가 무너진다.
## 4-5. 네트워크 문제인가 — 후보 ③을 판정한다
**앱 세션이 안 지워지는 이유가 「요청이 못 닿아서」일 수도 있다.**
그러면 구현이 아니라 네트워크를 고쳐야 한다. **갈라야 한다.**
Keycloak 파드에는 `curl` 이 없으므로 **같은 네임스페이스에 임시 파드**를 띄운다.
**하기****미검증** (원래 실행의 명령 원문은 기록에 없다. 출력은 실측이다)
```bash
sudo kubectl -n keycloak-lab run c2probe --rm -it --restart=Never \
--image=curlimages/curl:8.11.1 --command -- sh
```
파드 안에서
```sh
nslookup app1.hyeonworks.com
curl -s -o /dev/null -w 'app1 %{http_code}\n' https://app1.hyeonworks.com/
```
**실측** — [`04-reachability.txt`](../../evidence/c2-backchannel-logout/04-reachability.txt)
```
=== ★ Keycloak 파드가 app1.hyeonworks.com 에 닿는가 ===
DNS 해석:
Address: 100.83.212.4
Non-authoritative answer:
HTTPS 도달:
HTTP 200 (0 이면 못 닿음)
```
**어디를 봐야 하는가**
| 값 | 뜻 |
|---|---|
| `Address: 100.83.212.4` | 클러스터 안에서 **공개 이름이 풀린다** |
| **`HTTP 200`** | **실제로 닿는다** |
| `HTTP 000` | curl 이 연결조차 못 했다 = **네트워크가 원인** |
**이 결과가 의미하는 것****후보 ③은 원인이 아니다.**
네트워크는 열려 있고, 그래도 세션은 남았다.
`exit` 으로 파드에서 나온다. `--rm` 이 지워 준다.
> **임시 파드는 Keycloak 파드의 완전한 대역이 아니다.** 같은 네임스페이스라
> DNS 와 대체로 같은 경로를 타지만, **NetworkPolicy 나 사이드카가 걸려 있으면
> 결과가 갈릴 수 있다.** 이 실험대에는 그런 것이 없어서 대역이 성립했다.
> 확인: `sudo kubectl -n keycloak-lab get networkpolicy` 가 비어 있는가.
**그리고 이 200 은 이 실험대의 특수 사정이다.**
이 실험대는 **tailnet + split DNS** 구성이라 클러스터 안에서 공개 이름을
불러도 되돌아온다(헤어핀). **운영에서는 안 되는 경우가 흔하다.**
> **백채널 로그아웃의 숨은 전제** — IdP 가 **앱의 공개 URL 로 서버에서 서버로**
> 요청을 보낼 수 있어야 한다. 앱이 사설망에 있고 IdP 가 밖에 있으면
> **설정을 해도 도달하지 못한다. 그때는 로그도 안 남고 조용히 실패한다.**
## 4-6. 그래서 왜 안 퍼졌는가
```
IdP 로그아웃
├─ ① Keycloak 이 backchannel.logout.url 로 POST 를 보낸다 (2절에서 설정함)
├─ ② 앱이 그 POST 를 받는 엔드포인트를 갖고 있다 ★ 없다 (1-2·1-3)
└─ ③ 앱이 logout token 을 검증하고 sid 로 세션을 찾아 지운다 ★ 없다
```
**②와 ③이 없다. ①만 설정해도 받을 사람이 없다.**
이것이 이 실험의 결론이고, **「한쪽만 고쳐서는 안 된다」를 실제로 해 봐서
확인한 것**이 이 가이드의 값이다.
## 4-7. 개념 — 백채널 로그아웃의 구조
```
사용자가 어느 앱에서든 로그아웃
Keycloak 이 SSO 세션에 붙은 client session 목록을 본다 (C-1 의 그 구조)
├──POST──▶ app1 의 backchannel.logout.url
└──POST──▶ app2 의 backchannel.logout.url
본문: logout_token (JWT)
{ "sid": "...", "sub": "...", "events": {...} }
```
### `sid` 가 여기서 쓰인다
**무엇인가.** `sid` 는 Keycloak 의 user session 식별자다.
**A-0 에서 확인한 그 `sid`** 다 — JWT·DB·관리 API 에서 같은 문자열이었던.
**왜 여기 나오나.** logout token 에 실려 오는 것이 `sid` 이고,
**앱은 「그 sid 로 만든 내 세션」을 찾아 지워야 한다.**
```
logout_token 의 sid → 앱이 자기 세션 저장소에서 그 세션을 찾아 지운다
```
**그래서 앱은 `sid → 자기 세션 ID` 역인덱스를 갖고 있어야 한다.**
Spring Security 는 이를 위해 `OidcSessionRegistry` 를 쓴다.
**없거나 틀리면.** 엔드포인트가 있어도 **어느 세션을 지울지 모른다.**
그리고 **여러 인스턴스가 있으면 그 레지스트리도 공유 저장소여야 한다**
B-1·B-2 에서 겪은 것과 **같은 문제가 한 겹 더 있다.** BFF 는 replica 2개다.
### 부분 실패는 어떻게 되는가
```
app1 로그아웃 성공, app2 는 응답 없음
└─ Keycloak 은 재시도하는가? 얼마나?
└─ 사용자는 app2 에서 여전히 로그인 상태다
```
**로그아웃은 원자적이지 않다.** 앱이 늘어날수록 「일부만 로그아웃된 상태」가
생길 확률이 올라간다. **이 실험은 그 재시도 동작을 측정하지 않았다.**
## 4-8. 구현하려면 무엇이 필요한가
| 계층 | 할 일 | 이 실험대의 상태 |
|---|---|---|
| **IdP** | 클라이언트마다 `backchannel.logout.url` 설정 | **완료** (2절) |
| **앱** | `.oidcLogout(oidc -> oidc.backChannel(...))` 활성화 | 없음 |
| **앱** | `OidcSessionRegistry` 를 **공유 저장소**로 (인스턴스가 여럿) | 없음 |
| **네트워크** | IdP → 앱 공개 URL 도달 | **됨** (4-5). 운영은 확인 필요 |
| **oauth2-proxy** | **지원하지 않는다.** 별도 방안이 필요하다 | — |
**마지막 줄이 C-1 과 맞물린다** — app1(BFF)은 구현할 수 있지만
app2(oauth2-proxy)는 못 한다. **한 SSO 안에서 로그아웃 전파가 앱마다 다르게
동작하게 된다.** C-1 이 「두 앱이 같은 user session 을 공유한다」를 보여줬는데,
**그 공유가 로그아웃까지는 안 간다.**
---
# 5. 복구
## 5-1. 클라이언트 속성을 되돌린다
**하기** — 2-1 의 백업을 먼저 읽는다
```bash
cat ~/c2-bff-attributes-backup.json
```
**어디를 봐야 하는가** — 원래 무엇이 있었는가. 비어 있었으면 빈 객체로,
값이 있었으면 그 값으로 되돌린다.
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update "clients/$CID" -r keycloak-patterns -s 'attributes={}'
```
**확인** — 1-1 과 같은 명령으로 사라졌는지 본다.
> **그대로 둬도 무방하다.** 받을 엔드포인트가 없으므로 이 설정 하나로는
> 아무 일도 안 일어난다 — **그게 이 실험의 결론이었다.**
> 다만 나중에 앱을 고쳤을 때 **왜 갑자기 동작하는지 모르게 되므로**,
> 실험이 남긴 설정이라는 것을 기억하거나 지운다.
## 5-2. 세션을 정리한다
**하기**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "delete from offline_client_session" -c "delete from offline_user_session"
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
```
브라우저 쿠키(`auth`·`app1`·`app2`)도 지우거나 시크릿 창을 새로 연다.
## 5-3. 빌린 것을 돌려준다
**app2 는 Grafana 의 이름이다.** C 층이 끝났으면 여기서 되돌린다.
**하기**
```bash
sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
sudo kubectl apply -f ~/grafana-ingress-backup.yaml
curl -sI https://app2.hyeonworks.com/ | head -3
```
**어디를 봐야 하는가** — app2 가 다시 Grafana 로 가는가.
백업 파일이 없으면 [B-7 1-1](b7-cookie-secret-rotation.md#1-1-먼저-grafana-ingress-를-백업한다)
을 다시 읽는다 — **그때 떠 뒀어야 하는 파일이다.**
oauth2-proxy 배포까지 걷어내려면
```bash
sudo kubectl delete -f deploy/lab/k8s/b7-oauth2-proxy.yaml
```
## 5-4. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 클라이언트 속성 | 1-1 의 `get clients … --fields attributes` | `backchannel.logout.url`**없다** (지웠다면) |
| Keycloak 세션 | 1-4 의 realm 조인 카운트 | `0` |
| 앱 세션 | `redis-cli --scan --pattern '*'` | 비었다 |
| 임시 파드 | `sudo kubectl -n keycloak-lab get pod c2probe` | `NotFound` (없어야 정상) |
| 파드 | `sudo kubectl -n keycloak-lab get pods` | 전부 `Running` |
| Ingress | `sudo kubectl -n observability get ingress grafana` | 있다 |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/` | `200` |
임시 파드가 남아 있으면 (`--rm` 이 안 먹은 경우):
```bash
sudo kubectl -n keycloak-lab delete pod c2probe --ignore-not-found
```
> **이 실험이 재지 않은 것**
> · ②·③을 실제로 **구현한 뒤** 전파가 되는지 — 코드를 고쳐야 한다
> · Keycloak 이 요청을 보내기는 했는지 (DEBUG 로그를 켜지 않았다)
> · 부분 실패 시 **재시도 정책** (4-7)
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| **로그아웃했는데 아무 변화가 없다** | **로그아웃 전 세션이 이미 0 이었다** | realm 조인해서 먼저 센다 — 1-4 |
| `kcadm -s "attributes.backchannel.logout.url=…"``exit 1` | **점 표기가 안 먹는다** | JSON 으로 통째로 — 2-2·2-3 |
| JSON 으로 넣었더니 다른 속성이 사라졌다 | `attributes=` 는 **통째로 교체**한다 | 먼저 백업 — 2-1 |
| `$CID` 가 비었다 | `--format csv --noquotes` 출력이 예상과 다르다 | `echo "$CID"` 로 먼저 확인 — 2-1 |
| 세션 수가 안 맞는다 | `master` 의 admin 세션이 섞인다 | realm 을 조인한다 — 1-4 (C-1 과 같은 실수) |
| `grep -rn … bff/src/main/java/` 가 빈 출력 | **정말 없거나**, 경로가 틀렸다 | `ls` 로 디렉터리 존재 확인 — 1-2 |
| 후보 경로가 `404` 가 아니라 `302` | **핸들러가 없어 인증 요구로 떨어진 것** | 302 도 「없다」의 신호다 — 1-3 |
| 로그의 `backchannel` 0줄을 근거로 삼고 싶다 | **DEBUG 레벨이면 안 찍힌다** | 판정 근거로 쓰지 않는다 — 4-4 |
| 임시 파드에서 `HTTP 000` | 클러스터 안에서 공개 이름이 안 풀린다 | **운영에서는 그게 정상일 수 있다** — 4-5 |
| `kubectl exec keycloak-0 -- curl``exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 임시 curl 파드 — 4-5 |
| `kcadm` 이 전부 `401` | 파드 재시작으로 kcadm 세션이 날아갔다 | `config credentials` 를 다시 — 1-1 |
| Keycloak 재시작 후 로그인 폼이 안 넘어간다 | **인증 세션 쿠키가 무효화된 상태로 폼을 재사용했다** | 새 탭에서 주소부터 다시 연다 |
| 실험이 끝났는데 Grafana 가 안 열린다 | Ingress 복구를 안 했다 | 5-3 |
---
# 다음
| | C-2 가 남긴 것 |
|---|---|
| **구현** | `.oidcLogout()` 활성화 + `OidcSessionRegistry` **공유 저장소** (BFF replica 2개) |
| **oauth2-proxy** | 백채널 로그아웃 미지원 — **SSO 안에서 앱마다 동작이 갈린다** |
| **운영** | IdP → 앱 도달성이 전제다. 안 되면 **조용히 실패한다** — 로그도 안 남는다 |
| [C-1](c1-multi-app-sso.md) 과 연결 | 두 앱이 user session 을 공유한다. **그 공유가 로그아웃까지는 안 간다** |
| [B-2](../../experiment-b2-multi-instance-session.md) 와 연결 | 로그아웃이 지우는 것은 지금도 **세 곳 중 하나뿐**이다 |
| 방법론 | **주입 대상이 실제로 존재하는지 먼저 확인한다.** 이 실험이 그걸로 한 번 헛돌았다 |