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>
717 lines
31 KiB
Markdown
717 lines
31 KiB
Markdown
# 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:44–14: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
|
||
```
|
||
|
||
**어디를 봐야 하는가** — **로그인 화면이 뜨는가.**
|
||
|
||
**실측** — 뜨지 않았다.
|
||

|
||
|
||
**이 결과가 의미하는 것** — **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
|
||
```
|
||
|
||
**어디를 봐야 하는가** — **로그인 화면이 뜨는가.**
|
||
|
||
**실측** — 둘 다 로그인 화면 없이 그대로 열렸다.
|
||

|
||
|
||
> **★ 증거의 정직성에 관한 주의** — 위 스크린샷과 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 일부 소실」로 갈렸다 |
|
||
| 운영 | **세 층의 수명을 맞추거나, 어긋날 때의 동작을 정의해야 한다** |
|