Files
keycloak-pattern/docs/guides/experiments/c1-multi-app-sso.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

717 lines
31 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-1 재현 가이드 — 앱 둘에 SSO 를 걸고, IdP 세션을 죽여 본다
해설 문서: [`docs/experiment-c1-multi-app-sso.md`](../../experiment-c1-multi-app-sso.md) ·
증거 원문: [`docs/evidence/c1-multi-app-sso/`](../../evidence/c1-multi-app-sso/)
## 이 가이드가 끝나면
당신 터미널과 브라우저에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 두 번째 앱이 **로그인 화면 없이** 열리는 것 | 브라우저 |
| `user session 1``client session 2` 가 매달린 구조 | PostgreSQL |
| 서로 다른 구조의 두 앱이 같은 user session 을 공유하는 것 | `client` 조인 |
| **IdP 세션을 죽여도 두 앱이 그대로 열리는 것** | 브라우저 + Redis |
| `logout-all` 이 오류 없이 아무것도 안 하는 것 | 세션 수가 안 변한다 |
| realm 을 안 보고 세면 `master` 의 admin 세션에 속는 것 | `realm` 조인 |
## 전제
- [`B-2`](../../experiment-b2-multi-instance-session.md) 의 **app1(BFF)**
[`B-7`](b7-cookie-secret-rotation.md) 의 **app2(oauth2-proxy)****둘 다** 떠 있다.
이 실험은 그 둘이 있어야 성립한다 — 없으면 SSO 가 아니라 로그인 한 번이다.
- `app2.hyeonworks.com` 은 **Grafana 에서 빌린 이름**이다(B-7 의 주의).
이 실험이 끝나면 [5-3](#5-3-빌린-것을-돌려준다) 에서 되돌린다.
- **브라우저가 필요하다.** SSO 는 브라우저 쿠키가 만드는 현상이고, `curl` 로는
「로그인 화면이 안 떴다」를 볼 수 없다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
- **Keycloak 이미지에는 `curl``wget` 도 없다**(`exit 127`). `kcadm.sh`
파드 안에 있으므로 **항상 `kubectl exec` 로 감싼다.**
- 이 실험대에는 **`jq` 가 없다.**
## 주의 — 이건 세션을 전부 지우고 시작하는 실험이다
기준선을 만들려고 **Keycloak 세션 테이블을 직접 지우고, Redis 를 비우고,
Keycloak StatefulSet 을 재시작한다.** 그 순간 **지금 로그인해 있는 모든 사람이
끊긴다.** 실험대에서만 한다.
전 구간 약 20분이고, Keycloak 재시작에 1~2분이 든다.
중간에 그만두려면 [5. 복구](#5-복구) 로 간다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 14:4414:48 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 값은 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트로 했고 SQL 원문은 기록에 없다 |
세션 id·Redis 키·클라이언트 UUID 는 **당신 환경에서 다르다.** 이 문서는
자리표시자(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
예시로 실린 값은 전부 위 수집 기록의 실제 값이다.
---
# 0. 왜 이 실험을 하는가
원래 질문은 한 줄이었다.
> *"SSO 를 추가하게 되면 어떻게 달라지는지"*
「달라진다」에는 두 방향이 섞여 있다 — 편해지는 쪽과 위험해지는 쪽.
위험 쪽의 통념은 이렇다.
| | 예측 |
|---|---|
| 통념 | SSO 를 붙이면 **IdP 가 단일 장애점**이 된다. IdP 가 죽으면 다 죽는다 |
| **실측** | **절반만 맞다.** 로그인 **경로**는 그렇고, **이미 로그인한 사용자**는 아니다 |
**둘 중 어느 쪽인지는 IdP 세션만 죽여 보면 판정된다.** 그게 이 실험이다.
핵심은 **수명이 세 층으로 나뉘어 있다는 것**이다.
```
① IdP 세션 (Keycloak) ssoSessionIdleTimeout
② 앱 세션 (BFF / oauth2-proxy) 각자 30분 / 1시간
③ access token 60초
①을 지워도 ②는 자기 수명을 산다
```
**로그아웃이 지우는 것은 ① 뿐이다.** 이 실험대에는 서로 완전히 다르게
세션을 다루는 앱이 둘 있어서, ②가 어떻게 살아남는지를 두 형태로 동시에 볼 수 있다.
```
app1.hyeonworks.com → BFF 서버 세션 (Redis) + 토큰 (PostgreSQL)
app2.hyeonworks.com → oauth2-proxy 쿠키 티켓 + 세션 (Redis)
둘 다 realm keycloak-patterns
```
**우연히 좋은 실험대가 됐다.** B-2 와 B-7 에서 각기 다른 이유로 만든 두 앱이
같은 IdP 를 쓰면서 세션을 정반대로 다룬다.
---
# 1. 기준선 — 깨끗한 상태를 만든다
넓은 것부터 좁혀 간다.
```
앱 둘이 살아 있나 → 세션을 지운다 → 안 지워진다 → 왜 → 세는 법을 고친다
```
## 1-1. 두 앱이 다 떠 있나
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide
sudo kubectl -n keycloak-lab get ingress
```
**어디를 봐야 하는가**`bff``oauth2-proxy`**둘 다** `Running` 이고,
Ingress 에 `app1.hyeonworks.com``app2.hyeonworks.com`**둘 다** 있는가.
**확인** — 밖에서
```bash
curl -s -o /dev/null -w 'app1 %{http_code}\n' https://app1.hyeonworks.com/
curl -s -o /dev/null -w 'app2 %{http_code}\n' https://app2.hyeonworks.com/
```
**실측** — [`01-baseline.txt`](../../evidence/c1-multi-app-sso/01-baseline.txt)
```
app1 HTTP 200 / app2 HTTP 200
```
> **`app2` 가 Grafana 로 간다면** B-7 의 Ingress 가 없는 것이다.
> B-7 의 [1-1~1-2](b7-cookie-secret-rotation.md#1-1-먼저-grafana-ingress-를-백업한다) 를 먼저 한다.
## 1-2. 세션을 지우려고 시도한다 — 그리고 실패를 본다
**kcadm 을 먼저 로그인시킨다.** 파드가 재시작되면 세션이 사라지고 이후 모든
명령이 `401` 이 된다. **1-3 에서 실제로 재시작하므로 그때 다시 해야 한다.**
**하기**
```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 \
create realms/keycloak-patterns/logout-all
```
**확인** — 세션이 정말 지워졌는지 센다. **미검증** (증거에는 이 SQL 의 원문이 없다)
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from offline_user_session where offline_flag='0'"
```
**실측** — [`01-baseline.txt`](../../evidence/c1-multi-app-sso/01-baseline.txt)
```
=== 깨끗한 상태로 초기화 ===
DELETE 1
=== 기준선 ===
Keycloak 온라인 세션: 4
Redis 키: 0
```
**어디를 봐야 하는가****`4`.** 0 이 아니다.
**이 결과가 의미하는 것****`logout-all` 이 안 먹었다.** 오류도 안 났다.
세션이 그대로 4개 남아 있다.
> **★ 해설 문서 정정** — 이 문서는 처음에 이 값을 `0` 으로 인쇄했다.
> 증거 [`01-baseline.txt`](../../evidence/c1-multi-app-sso/01-baseline.txt) 는 **`4`** 다.
> **`0` 은 그 다음 단계(DB 직접 삭제 + 재시작)의 값이었다.**
> 이 가이드는 증거를 따른다 — **여기서 4 가 나오는 것이 정상이다.**
**왜 안 먹었나****캐시 때문이다.** A-1 에서 확인했듯 Keycloak 은 세션을
DB 에서 읽되 **캐시로 답한다.** 관리 API 가 무효화를 걸어도 각 노드의 캐시가
그대로면 세션은 살아 있는 것처럼 보인다.
## 1-3. 그래서 DB 를 직접 지우고 Keycloak 을 재시작한다
**하기** — 자식 테이블부터 지운다
```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"
```
**형태**
```
DELETE 2
DELETE 4
```
**하기** — 앱 세션도 비운다
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli flushall
```
> **`flushall` 은 이 Redis 전체를 지운다.** BFF 세션·oauth2-proxy 세션·
> B-5 가 남긴 `b5:pvc` 까지 전부다. **기준선을 만드는 자리라서 의도한 것**이고,
> 실험 도중에는 절대 쓰지 않는다([B-7a](b7a-orphan-session.md) 5-2 참고).
**하기** — 캐시를 비우려면 프로세스를 새로 띄우는 수밖에 없다
```bash
sudo kubectl -n keycloak-lab rollout restart statefulset/keycloak
sudo kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s
```
**되돌리기** — 없다. **지운 세션은 안 돌아온다.** 다시 로그인하면 된다.
**확인** — 이제 비었는가
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from offline_user_session where offline_flag='0'"
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
```
**실측** — [`01-baseline.txt`](../../evidence/c1-multi-app-sso/01-baseline.txt)
```
Redis 키: 0
```
**어디를 봐야 하는가** — Redis 키 **0**, 세션 수 **0**.
여기서도 0 이 아니면 재시작이 안 끝났거나 누가 로그인 중이다.
**★ kcadm 세션이 날아갔다.** 1-2 의 `config credentials`**다시 친다.**
## 1-4. ★ 세는 법을 먼저 고친다 — realm 을 본다
**이 절을 건너뛰면 4절의 결론을 반대로 읽는다.**
`offline_user_session` 에는 **모든 realm 의 세션**이 들어 있다. 그리고
`kcadm` 을 쓰는 순간 **`master` realm 에 admin 세션이 생긴다.**
그러니 그냥 세면 **내가 만든 노이즈를 남의 세션으로 읽는다.**
**확인** — 틀린 방법(전체를 센다)
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from offline_user_session where offline_flag='0'"
```
**확인** — 맞는 방법(realm 을 조인한다). **미검증**
```bash
sudo 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'"
```
**어디를 봐야 하는가****`realm` 열.** `keycloak-patterns` 만이 이 실험의
대상이고, `master` 는 **당신이 방금 `kcadm` 을 쳐서 생긴 것**이다.
**이 결과가 의미하는 것** — 이 한 열 때문에 원래 실행은 **「안 지워졌다」로
오독할 뻔했다.** 해설 문서가 「세 번째가 특히 위험했다」고 쓴 것이 이 실수다.
> **여기서부터 세션 수를 말할 때는 항상 realm 을 붙인다.**
> 「세션 1개」가 아니라 「`keycloak-patterns` 세션 0개, `master` 1개」다.
---
# 2. 주입 — 두 앱에 차례로 들어간다
여기서 SSO 상태를 만든다. **되돌리기는 간단하다** — 5절의 초기화를 다시 하면
된다. 파괴적인 조작은 4절에 있다.
## 2-1. app1 에 로그인한다 — 로그인 화면이 나온다
**하기** — 브라우저에서
```
https://app1.hyeonworks.com/ → labuser / labpass
```
**어디를 봐야 하는가****Keycloak 로그인 화면이 뜨는가.**
주소창이 이렇게 바뀐다.
**실측** — 해설 문서에 남은 형태
```
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/auth
?client_id=bff-confidential&...
→ Sign in to keycloak-patterns
```
**이 결과가 의미하는 것** — 첫 앱에서는 **당연히 로그인 화면이 나온다.**
이것이 2-2 의 대조군이다. **이걸 안 보면 「app2 에서 안 뜬 것」이 특별한
일인지 알 수 없다.**
## 2-2. 로그인 직후 상태를 잰다
**확인** — 1-4 의 맞는 쿼리를 그대로 쓴다
```bash
sudo 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' and r.name='keycloak-patterns'"
```
**실측** — [`02-after-app1-login.txt`](../../evidence/c1-multi-app-sso/02-after-app1-login.txt)
```
=== app1 로그인 직후 Keycloak 세션 ===
user_session_id | client_sessions
--------------------------+-----------------
oqOjHekin4JU-BZjgQLjUByW | 1
(1 row)
```
**어디를 봐야 하는가****`user_session_id` 를 적어 둔다.** 2-3 과 3절에서
계속 쓴다. 그리고 **`client_sessions` 가 1** 이다.
**확인** — 저장소 두 곳
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
```
**실측**
```
Redis 키: 1
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
PostgreSQL authorized client: 1 행
```
**어디를 봐야 하는가** — Redis 키 이름의 **접두사 `bff:session:sessions:`**.
이 접두사가 「BFF 가 만든 세션」이라는 뜻이고, 3절에서 프록시 것과 갈라진다.
`authorized client` 는 BFF 가 토큰을 넣어 둔 PostgreSQL 행이다. **미검증**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from oauth2_authorized_client"
```
**이 결과가 의미하는 것****한 번 로그인했는데 상태가 세 곳에 생겼다.**
Keycloak 세션 · Redis 세션 · PostgreSQL 토큰. 4절에서 이 셋의 운명이 갈린다.
## 2-3. app2 를 방문한다 — 여기가 SSO 다
**하기** — **같은 브라우저의 새 탭**에서
```
https://app2.hyeonworks.com/api/echo
```
**어디를 봐야 하는가****로그인 화면이 뜨는가.**
**실측** — 뜨지 않았다.
![app2 가 로그인 없이 열린다](../../evidence/c1-multi-app-sso/c1-sso-app2-no-login-screen.png)
**이 결과가 의미하는 것****SSO 가 동작한다.** app2 는 Keycloak 으로
리다이렉트했지만, Keycloak 에 이미 세션이 있어서 **묻지 않고 바로 돌려보냈다.**
> **다른 브라우저나 시크릿 창에서 열면 안 된다.** SSO 를 만드는 것은
> `auth.hyeonworks.com` 에 붙은 **브라우저 쿠키**다. 창이 다르면 쿠키가 없고,
> 그러면 로그인 화면이 뜨는 것이 정상이다.
---
# 3. 주입이 만든 구조를 확인한다
## 3-1. user session 하나에 client session 둘
**확인** — 2-2 와 **똑같은 쿼리**
```bash
sudo 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' and r.name='keycloak-patterns'"
```
**실측** — [`03-after-app2-visit.txt`](../../evidence/c1-multi-app-sso/03-after-app2-visit.txt)
```
=== app2 방문 후 — 로그인 화면 없이 통과했는가 ===
user_session_id | client_sessions
--------------------------+-----------------
oqOjHekin4JU-BZjgQLjUByW | 2
(1 row)
```
**어디를 봐야 하는가****`user_session_id` 가 2-2 와 같고, `client_sessions`
만 1 → 2 로 늘었다.**
**이 결과가 의미하는 것** — **두 번째 로그인이 아니라 같은 로그인에 앱이
하나 붙은 것이다.** 이것이 SSO 의 데이터 구조다.
```
user session (사용자 · 브라우저 하나당 하나)
├─ client session : bff-confidential
└─ client session : oauth2-proxy
```
## 3-2. 어느 클라이언트가 붙었는가
**확인****미검증** (증거에는 이 SQL 의 원문이 없다. 출력은 실측이다)
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select cs.client_id, c.client_id as name
from offline_client_session cs join client c on c.id = cs.client_id
where cs.user_session_id = 'oqOjHekin4JU-BZjgQLjUByW'"
```
**실측** — [`03-after-app2-visit.txt`](../../evidence/c1-multi-app-sso/03-after-app2-visit.txt)
```
=== 어느 클라이언트가 붙었는가 ===
client_id | name
--------------------------------------+------------------
9055fa46-6abb-4d6d-a339-8a9183bbf26d | bff-confidential
80431dbc-af81-4673-9790-ad06d1570b2e | oauth2-proxy
(2 rows)
```
**어디를 봐야 하는가****`client_id` 열은 UUID 이고, 사람이 아는 이름은
`client` 테이블에 있다.** 조인 없이 보면 UUID 두 개만 나와서 어느 앱인지
알 수 없다.
**이 결과가 의미하는 것** — 구조가 완전히 다른 두 앱이 **같은 user session
아래에 나란히** 있다. Keycloak 은 앱이 세션을 어떻게 다루는지 모르고,
알 필요도 없다.
## 3-3. 저장소 세 곳이 각자 무엇을 들고 있는가
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
```
**실측** — [`03-after-app2-visit.txt`](../../evidence/c1-multi-app-sso/03-after-app2-visit.txt)
```
=== 저장소 상태 ===
Redis 키:
_oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
PostgreSQL authorized client: 1 행
```
**어디를 봐야 하는가** — **같은 Redis 에 접두사가 다른 두 세션**이 있다.
`bff:session:sessions:` 는 Spring Session 이 쓰는 이름이고,
`_oauth2_proxy-` 는 프록시가 쓰는 이름이다.
**이 결과가 의미하는 것** — 「세션 저장소를 공유한다」는 말이 **「같은 Redis 를
쓴다」일 뿐 「같은 세션을 본다」가 아니다.** 둘은 서로의 키를 모른다.
[B-7a](b7a-orphan-session.md) 에서 `FLUSHDB` 를 금지한 이유가 이것이다.
### 개념 — 두 층으로 나뉘어 있는 이유
**무엇인가.** Keycloak 은 세션을 `user session`(사람 하나)과
`client session`(그 사람이 쓰는 앱 하나)으로 나눠 둔다.
**왜 여기 나오나.** A층·B층에서 본 두 사건이 서로 다른 층을 건드렸다.
| | 무엇이 사라졌나 | 결과 |
|---|---|---|
| **A-3** DB 크래시 | `user_session` 행이 통째로 | **모든 앱이 끊긴다** |
| **B-3** refresh 재사용 탐지 | **`client_session` 만** | **그 앱만 끊긴다** |
**두 층이 나뉘어 있는 이유가 SSO 다.** 앱 하나의 사고가 다른 앱으로 번지지
않게 하려면 client session 이 따로 있어야 한다.
**없거나 틀리면.** 한 층뿐이라면 B-3 의 재사용 탐지 한 번이 **모든 앱을**
끊었을 것이다.
---
# 4. 관찰 — IdP 세션만 죽인다
## 4-1. 무엇을 지우는지 먼저 정한다
**지우려는 것은 ①(IdP 세션)뿐이다.** ②(앱 세션)와 ③(토큰)은 손대지 않는다.
**그 구분이 이 실험의 전부다.**
**되돌리기** — 다시 로그인하면 된다. 파괴적이지만 회복은 쉽다.
## 4-2. 지우는 방법을 고른다 — 두 개는 안 먹는다
**하기** — 세션 id 를 지목해서 지운다. **미검증 · 안 먹는다**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
delete sessions/oqOjHekin4JU-BZjgQLjUByW -r keycloak-patterns
```
**어디를 봐야 하는가****오류도 안 나고 세션도 안 줄어든다.**
1-2 의 `logout-all` 과 같은 유형이다.
**하기** — 사용자 단위로 끊는다. **이건 먹는다**
```bash
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
```
**어디를 봐야 하는가**`echo "$USERID"` 가 **UUID 한 줄**인가.
비어 있거나 여러 줄이면 `--format csv --noquotes | tail -1` 가 다른 것을 잡은
것이다. 그 상태로 다음 명령을 치면 엉뚱한 경로를 부른다.
> **자리표시자를 두지 않으려고 두 단계로 나눴다.** 한 줄로 이어 붙일 수도
> 있지만, **그러면 UID 가 비었을 때 그 사실이 안 보인다.**
## 4-3. IdP 쪽은 정말 끊겼는가 — realm 을 보고 센다
**확인** — 1-4 의 맞는 쿼리
```bash
sudo 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'"
```
**실측** — [`04-sso-session-killed.txt`](../../evidence/c1-multi-app-sso/04-sso-session-killed.txt)
```
=== 사용자 단위 로그아웃 (IdP 세션만 끊는다) ===
남은 Keycloak 세션: 1
=== 남은 세션의 realm 과 client ===
user_session_id | realm | clients
--------------------------+--------+---------
E1q5xI7tt4U_WhZpW7rEPIF2 | master | 1
(1 row)
```
**어디를 봐야 하는가****「남은 세션 1」과 「그 1의 realm 이 `master`」를
같이 본다.**
**이 결과가 의미하는 것**`keycloak-patterns` 세션은 **0** 이다.
남은 하나는 **당신이 `kcadm` 을 쳐서 생긴 admin 세션**이다.
> **★ 여기가 이 실험에서 가장 잘 틀리는 자리다.** 「1이 남았네, 로그아웃이
> 안 먹었구나」로 읽으면 4-4 의 결론이 통째로 뒤집힌다. **숫자 옆에 realm 을
> 붙이지 않으면 그 숫자는 아무 뜻이 없다.**
## 4-4. ★ 앱 세션은 그대로 남아 있다
**확인** — 3-3 과 **똑같은 명령**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
```
**실측** — [`04-sso-session-killed.txt`](../../evidence/c1-multi-app-sso/04-sso-session-killed.txt)
```
=== 두 앱의 애플리케이션 세션은 그대로인가 ===
_oauth2_proxy-6b028a70f69c8f0da9966eb36972dff2
bff:session:sessions:6e0d9af4-2c8f-47d2-bf83-8b1e9670c679
PostgreSQL authorized client: 1 행
→ IdP 세션은 없어졌는데 앱 세션은 남아 있다면, 두 계층의 수명이 어긋난 것이다
```
**어디를 봐야 하는가****키 이름이 3-3 과 글자 하나까지 같다.**
아무것도 안 지워졌다.
**이 결과가 의미하는 것****로그아웃은 ①만 지웠다.** ②도 ③도 아무도 안 건드렸다.
## 4-5. 브라우저로 두 앱을 다시 연다
**하기** — 아까 그 브라우저에서
```
https://app1.hyeonworks.com/
https://app2.hyeonworks.com/api/echo
```
**어디를 봐야 하는가****로그인 화면이 뜨는가.**
**실측** — 둘 다 로그인 화면 없이 그대로 열렸다.
![IdP 세션이 없어도 앱은 동작한다](../../evidence/c1-multi-app-sso/c1-apps-alive-after-idp-logout.png)
> **★ 증거의 정직성에 관한 주의** — 위 스크린샷과 2-3 의 스크린샷은
> **바이트 단위로 동일한 파일**이다(md5 `2c703176…`). 두 시점의 화면이 실제로
> 같은 내용이었기 때문이며 조작이 아니지만, **그래서 두 시점을 구별하는 증거가
> 되지 못한다.** 구별은 [`03-`](../../evidence/c1-multi-app-sso/03-after-app2-visit.txt) 과
> [`04-`](../../evidence/c1-multi-app-sso/04-sso-session-killed.txt) 의 터미널 출력이 한다 —
> `client_sessions` 1→2, 그리고 IdP 세션 삭제 후에도 Redis 키가 남아 있는 것.
> **화면이 같아 보인다는 것 자체가 이 실험의 결론**이라, 화면만으로는 증명이 안 된다.
## 4-6. 왜 그런가 — 세 개의 독립된 수명
```
① IdP 세션 (Keycloak) ssoSessionIdleTimeout 1800초
② 앱 세션 (BFF / oauth2-proxy) 각자 30분 / 1시간
③ access token 60초
①을 지워도 ②는 자기 수명을 산다
```
**앱은 매 요청마다 IdP 에 물어보지 않는다.** 자기 세션이 살아 있으면 그걸로
답한다. **그래서 ①이 사라진 것을 모른다.**
**그러면 언제 알게 되는가.**
| | 언제 끊기는가 |
|---|---|
| BFF | access token 이 만료되어 **refresh 를 시도할 때**`Session not active` |
| oauth2-proxy | 쿠키 만료(1시간) 또는 **토큰 갱신을 시도**할 때 |
**즉시가 아니라 지연되어 끊긴다.** 최대 지연은 access token 수명(60초)이 아니라
**앱이 다음에 IdP 를 부를 때까지**다.
> **B-2 에서 「로그아웃했는데 다시 들어가진다」를 겪은 것의 반대편이다.**
> 거기서는 앱 세션을 지웠는데 IdP 세션이 남아 재로그인이 됐고,
> 여기서는 IdP 세션을 지웠는데 앱 세션이 남아 계속 들어가진다.
> **두 방향 모두 「한쪽만 지우면 다른 쪽이 남는다」이다.**
**확인** — 실제로 끊기는 순간을 보고 싶으면 기다린다. **미검증**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get realms/keycloak-patterns --fields accessTokenLifespan,ssoSessionIdleTimeout
```
그 시간이 지난 뒤 app1 을 새로고침하면 로그인 화면으로 떨어진다.
## 4-7. 그래서 SSO 의 대가는 무엇인가
원래 질문에 대한 답이다.
| | 앱이 하나일 때 | **SSO 일 때** |
|---|---|---|
| 로그인 | 앱마다 | **한 번** |
| IdP 가 죽으면 | 그 앱만 로그인 불가 | **모든 앱이 로그인 불가** |
| **이미 로그인한 사용자** | — | **★ 영향 없다** (앱 세션이 살아 있으므로) |
| 로그아웃 | 그 앱만 | **전 앱을 끊으려면 백채널 로그아웃이 필요** |
| 세션 수명 | 하나 | **세 층이 각자** — 어긋나면 예측이 어렵다 |
**IdP 는 「로그인 경로」의 단일 장애점이지 「이미 로그인한 사용자」의 단일
장애점이 아니다.** A-2(DB 상실)와 합치면 장애의 모양이 이렇게 된다.
```
Keycloak DB 죽음 → 새 로그인 불가 (전 앱)
→ 이미 로그인한 사용자는 앱 세션 수명 동안 계속 쓴다
→ 그 뒤 갱신 시점에 한꺼번에 끊긴다
```
**장애가 즉시 전면화되지 않고 「앱 세션 수명만큼 지연되어 몰려온다」.**
이것이 SSO 구조의 장애 모양이고, **모니터링이 어려운 이유**다.
**그리고 마지막 줄이 다음 실험을 부른다** — 전 앱을 끊으려면 백채널
로그아웃이 필요하다. **그게 되는지는 [C-2](c2-backchannel-logout.md) 가 잰다.**
---
# 5. 복구
## 5-1. 세션을 정리한다
**하기** — 1-3 과 같은 절차
```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
```
**그냥 둬도 된다.** 앱 세션은 수명(30분 / 1시간)이 지나면 사라지고,
IdP 세션은 이미 없다. **정리는 다음 실험을 깨끗하게 시작하려는 것뿐이다.**
## 5-2. 브라우저 쿠키를 지운다
**하기**`auth.hyeonworks.com`·`app1`·`app2` 의 쿠키를 지우거나
**시크릿 창을 새로 연다.**
**왜** — 서버 세션을 다 지워도 **브라우저에 낡은 쿠키가 남는다.**
다음 실험에서 「왜 로그인 화면이 안 뜨지」로 헤매는 원인이 대개 이것이다.
## 5-3. 빌린 것을 돌려준다
**app2 는 Grafana 의 이름이다.** C-2 를 이어서 하지 않을 거라면 지금 되돌린다.
**하기**
```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 로 가는가.
> **[C-2](c2-backchannel-logout.md) 를 이어서 할 생각이면 아직 돌려주지 않는다.**
> C-2 가 두 앱을 그대로 쓴다. **그 대신 C-2 가 끝난 뒤에 반드시 복구한다.**
## 5-4. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| Keycloak 세션 | 1-4 의 realm 조인 쿼리 | `keycloak-patterns` **0** (`master` 는 있을 수 있다) |
| 앱 세션 | `redis-cli --scan --pattern '*'` | 비었거나 남기기로 한 것만 |
| 토큰 | `select count(*) from oauth2_authorized_client` | 0 |
| 파드 | `sudo kubectl -n keycloak-lab get pods` | 전부 `Running`, `keycloak` 둘 다 `1/1` |
| Ingress | `sudo kubectl -n observability get ingress grafana` | 있다 (돌려줬다면) |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://app1.hyeonworks.com/` | `200` |
> **이 실험이 재지 않은 것** — 4-6 의 「언제 끊기는가」를 **실제로 기다려서
> 확인하지 않았다.** IdP 세션을 지운 뒤 access token 수명이 지날 때까지 두고
> app1 을 새로고침하면 `Session not active` 가 나와야 한다. 재려면 그렇게 한다.
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| `logout-all` 이 오류 없이 아무 일도 안 한다 | **캐시.** DB 를 지워도 노드 캐시가 답한다 | DB 직접 삭제 + `rollout restart` — 1-3 |
| `kcadm delete sessions/<id>` 가 조용히 안 먹는다 | 같은 유형 | `users/<id>/logout` 을 쓴다 — 4-2 |
| 기준선 세션이 0 이 아니라 4 다 | **원래 실행도 4 였다.** 해설의 `0` 은 정정됐다 | 1-2 의 정정 박스 |
| 로그아웃했는데 세션이 1 남았다 | **`master` 의 admin 세션이다.** 당신이 `kcadm` 을 쳐서 생겼다 | realm 을 조인한다 — 1-4·4-3 |
| `kcadm` 이 전부 `401` | **재시작으로 kcadm 세션이 날아갔다** | `config credentials` 를 다시 — 1-2 |
| `$USERID` 가 비었다 | `--format csv --noquotes` 출력이 예상과 다르다 | `echo "$USERID"` 로 먼저 확인 — 4-2 |
| app2 에서 로그인 화면이 뜬다 | **다른 브라우저·시크릿 창이다.** SSO 쿠키가 없다 | 같은 창의 새 탭에서 연다 — 2-3 |
| app2 가 Grafana 로 간다 | B-7 의 Ingress 가 없다 | B-7 1-1~1-2 |
| `client_id` 가 UUID 뿐이라 어느 앱인지 모른다 | `client` 테이블을 조인해야 이름이 나온다 | 3-2 |
| Redis 를 비웠더니 app1 도 끊겼다 | **`flushall` 은 BFF 세션도 지운다** | 기준선에서만 쓴다 — 1-3 |
| 스크린샷 두 장이 똑같다 | **실제로 같은 파일이다.** 조작이 아니다 | 구별은 터미널 출력이 한다 — 4-5 |
| `kubectl exec keycloak-0 -- curl``exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | 밖에서 치거나 임시 curl 파드 |
---
# 다음
| 실험 | C-1 이 남긴 질문 |
|---|---|
| [C-2](c2-backchannel-logout.md) 백채널 로그아웃 | **이 실험이 C-2 가 왜 필요한지 보여준다** — IdP 로그아웃이 앱에 안 퍼진다. **원인은 아무도 구현하지 않았기 때문이다** |
| [D-1](../../experiment-d1-backup-restore.md) 백업·복구 | `user_session` 을 잃으면 **전 앱이 끊긴다.** 백업 범위에 들어간다 |
| [A-3](a3-database-crash.md) DB 크래시 | 여기서 본 두 층 구조가 거기서 「전체 소실 vs 일부 소실」로 갈렸다 |
| 운영 | **세 층의 수명을 맞추거나, 어긋날 때의 동작을 정의해야 한다** |