Files
keycloak-pattern/docs/guides/experiments/b7-cookie-secret-rotation.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

767 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.
# B-7 재현 가이드 — cookie secret 을 갈아치우고, 로그인해 있던 사람에게 무슨 일이 나는지 본다
해설 문서: [`docs/experiment-b7-cookie-secret-rotation.md`](../../experiment-b7-cookie-secret-rotation.md) ·
증거 원문: [`docs/evidence/b7-cookie-secret/`](../../evidence/b7-cookie-secret/)
## 이 가이드가 끝나면
당신 터미널과 브라우저에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| replica 두 개가 상태를 안 나누고도 로그인이 되는 것 | oauth2-proxy 로그 (시작한 replica ≠ 콜백 받은 replica) |
| 큰 쿠키가 프록시를 못 넘어 502 가 되는 것, 그리고 계층을 갈라 원인을 좁히는 법 | Traefik 직접 vs nginx |
| `--cookie-secret` 이 **단수**라는 것 | `oauth2-proxy --help` |
| 옛 쿠키가 `session ticket cookie failed validation` 로 죽는 것 | 프록시 로그 |
| **로그인 화면 없이 조용히 재로그인**되는 것 | 브라우저 |
| ★ 프록시가 **지우지 못한** 서버 세션이 Redis 에 남는 것 | `redis-cli --scan` |
## 전제
- [`05-keycloak`](../05-keycloak/) 가 끝나 있고, realm `keycloak-patterns`
클라이언트 `oauth2-proxy` 와 사용자 `labuser`(비밀번호 `labpass`)가 있다.
- Redis 가 `redis.keycloak-lab.svc:6379` 로 떠 있다.
- **브라우저가 필요하다.** 쿠키가 `HttpOnly` 이고 OIDC 흐름을 끝까지 걸어야
세션이 생긴다. `curl` 로 완주하려던 시도는 실패했다 — 「막히면」 표에 그 기록이 있다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
- 이 실험대에는 **`jq``yamllint` 도 없다.**
## 주의 — 이건 남의 도메인을 빌리고, 남의 세션을 끊는 실험이다
**두 가지를 건드린다.**
1. **`app2.hyeonworks.com` 은 평소 `observability` 네임스페이스의 Grafana 로
간다.** 인증서가 `auth`·`app1`·`app2` 세 이름만 덮고 있어서 네 번째 이름을
못 만든다. 그래서 **Grafana 의 Ingress 를 잠시 내리고 빌린다.**
**반드시 되돌린다** — [5-3](#5-3--grafana-ingress-를-되돌린다) 이 그 절차다.
백업을 뜨는 것이 [1-1](#1-1-먼저-grafana-ingress-를-백업한다) 의 첫 명령인 이유다.
2. **secret 을 바꾸면 그때 로그인해 있던 사람의 쿠키가 전부 무효가 된다.**
실험대에서만 한다.
전 구간 약 20분이다. 중간에 그만두려면 [5. 복구](#5-복구) 를 위에서부터 친다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 14:3514:42 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 값은 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행 기록에 이 명령의 출력은 없다 |
> **시계에 주의한다.** `kubectl` 로 보는 시각은 KST 인데 **oauth2-proxy 가 찍는
> 로그 타임스탬프는 UTC 다.** 증거의 로그가 `[2026/09/04 05:41:46]` 인 것과
> 수집 시각이 `14:3514:42 KST` 인 것은 **같은 시간대의 같은 순간**이다(KST = UTC+9).
> 이 어긋남을 모르고 로그를 뒤지면 9시간 전을 뒤지게 된다.
파드 이름·Redis 키·쿠키 값은 **당신 환경에서 다르다.** 이 문서는
자리표시자(`<...>`)를 쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다.
---
# 0. 왜 이 실험을 하는가
Q1 의 미지수 7 은 이렇게 물었다.
> *"OAuth2-Proxy 구조의 replica 들이 같은 cookie secret 을 어떻게 공유하고
> 교체하게 되는가. 교체하는 동안 로그인해 있던 사람은 어떻게 되는가."*
B-6 에서 Keycloak 은 **두 키를 동시에 들고** 무중단으로 회전했다. `kid`
있어서 「읽기는 여러 키, 쓰기는 하나」가 가능했기 때문이다.
| | 예측 |
|---|---|
| B-6 의 모양대로라면 | oauth2-proxy 도 **겹침 구간을 만들 수 있을 것** |
| **실측** | **★ 없다.** `--cookie-secret` 은 단수이고 쿠키에 key 식별자가 없다 |
**그리고 예측하지 않았던 것이 하나 더 나온다** — 사용자는 아무것도 못 느끼는데
**서버 쪽에 지워지지 않는 세션이 남는다.** 그 「지우지 못한다」를 이어서 재는
것이 [B-7a](b7a-orphan-session.md) 다.
핵심은 **상태를 어디에 두었는가**다.
```
BFF 인가 요청을 서버 메모리(HttpSession)에 둔다 → replica 를 넘으면 실패
oauth2-proxy 인가 요청을 쿠키에 두고 secret 으로 봉인한다 → replica 를 넘어도 성공
대신 secret 이 단일 지점
```
**공유할 상태가 없으면 공유 문제도 없다. 대신 secret 하나가 전부를 쥔다.**
---
# 1. 기준선 — 아무것도 바꾸기 전에
넓은 것부터 좁혀 간다.
```
Ingress 백업 → 배포 → replica 배치 → secret 키 이름 → 로그인 → Redis → 쿠키 모양
```
## 1-1. 먼저 Grafana Ingress 를 백업한다
**이것을 잊으면 실험이 끝나도 Grafana 가 안 돌아온다.**
**확인** — 지금 app2 가 무엇인지 먼저 본다
```bash
curl -sI https://app2.hyeonworks.com/ | head -3
```
**형태** — Grafana 로 가고 있으면 `302``/login` 을 가리킨다.
**하기** — 백업을 뜨고, 파일이 비지 않았는지 확인한다
```bash
sudo kubectl -n observability get ingress grafana -o yaml > ~/grafana-ingress-backup.yaml
wc -l ~/grafana-ingress-backup.yaml
grep -c 'app2.hyeonworks.com' ~/grafana-ingress-backup.yaml
```
**어디를 봐야 하는가** — 줄 수가 **0 이 아니고**, `app2.hyeonworks.com`
**1회 이상** 잡혀야 한다. `0` 이면 백업이 빈 파일이고, 그 상태로 진행하면
복구할 것이 없다.
**하기**
```bash
sudo kubectl -n observability delete ingress grafana
```
**실측** — [`01-deploy.txt`](../../evidence/b7-cookie-secret/01-deploy.txt)
```
=== Grafana ingress 를 잠시 내린다 (app2 를 빌린다) ===
grafana ingress 삭제
```
**되돌리기**`sudo kubectl apply -f ~/grafana-ingress-backup.yaml`
## 1-2. oauth2-proxy 를 배포한다
**하기**
```bash
sudo kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml
sudo kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
```
**실측** — [`01-deploy.txt`](../../evidence/b7-cookie-secret/01-deploy.txt)
```
secret/oauth2-proxy-secrets created
deployment.apps/oauth2-proxy created
service/oauth2-proxy created
ingress.networking.k8s.io/oauth2-proxy created
deployment "oauth2-proxy" successfully rolled out
```
**되돌리기**`sudo kubectl delete -f deploy/lab/k8s/b7-oauth2-proxy.yaml`
## 1-3. replica 두 개가 서로 다른 노드에 있는가
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -l app=oauth2-proxy -o wide
```
**실측** — [`01-deploy.txt`](../../evidence/b7-cookie-secret/01-deploy.txt)
```
oauth2-proxy-c76b49c59-8p5hl true kc-lab-1
oauth2-proxy-c76b49c59-b9928 true kc-lab-2
```
**어디를 봐야 하는가****파드 두 개, 서로 다른 노드.** 그리고 **파드 이름의
끝 다섯 글자**를 적어 둔다. 4절에서 로그를 읽을 때 「어느 replica 가 무엇을
했는지」를 이 글자로 가른다.
**이 결과가 의미하는 것** — replica 가 둘이라는 것이 Q1 의 질문 자체다.
하나면 「공유」라는 말이 성립하지 않는다.
## 1-4. 진입점이 살아 있는가
**확인**
```bash
curl -s -o /dev/null -w '/ %{http_code}\n' https://app2.hyeonworks.com/
curl -s -o /dev/null -w '/ping %{http_code}\n' https://app2.hyeonworks.com/ping
```
**실측** — [`01-deploy.txt`](../../evidence/b7-cookie-secret/01-deploy.txt)
```
=== 진입점 확인 ===
https://app2.hyeonworks.com/ HTTP 302
/ping HTTP 200
```
**어디를 봐야 하는가**
| 경로 | 정상 | 뜻 |
|---|---|---|
| `/` | `302` | 인증이 없으니 Keycloak 으로 보낸다 — **프록시가 일하고 있다** |
| `/ping` | `200` | 인증을 거치지 않는 헬스 경로 — **프록시 자체는 살아 있다** |
**두 값이 갈라지는 것이 중요하다.** `/ping` 도 안 되면 프록시가 안 뜬 것이고,
`/ping` 만 되면 프록시는 떴는데 앞단이 무언가를 막고 있는 것이다.
## 1-5. ★ 502 가 나면 — 계층을 가른다
원래 구성에서 **콜백이 계속 502** 였다. 이 절은 그때 무엇을 쳤는지다.
**502 를 안 만났으면 읽고 넘어간다.**
```
GET /oauth2/callback?state=...&code=... → 502 Bad Gateway
```
**502 는 「누가 냈는지」를 안 알려 준다.** 앞단 nginx 인지, 그 뒤 Traefik 인지,
파드인지. **한 겹씩 벗겨서 좁힌다.**
**확인** — nginx 를 건너뛰고 Traefik 에 직접 묻는다
```bash
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/ping
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/
```
**실측**
```
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/ping → 200
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/ → 302
```
**어디를 봐야 하는가****Traefik 직접은 정상이다.** 그러면 502 를 내는 것은
그 앞의 nginx 다. 그리고 502 는 **쿠키를 설정하는 응답에서만** 났다.
**이 결과가 의미하는 것** — oauth2-proxy 는 기본적으로 **세션 전체를 쿠키에
담는다.** 그 `Set-Cookie` 가 nginx 의 `proxy_buffer_size` 를 넘겼다.
> **B-4 에서 본 헤더 크기 절벽이 이번에는 응답 쪽에서 나타났다.**
> 거기서는 요청 헤더가 8KB 에서 400 이 됐고, 여기서는 응답 헤더가 프록시
> 버퍼를 넘겨 502 가 됐다. **같은 종류의 한계다.**
**해결** — 세션을 Redis 로 옮긴다. 매니페스트에 이미 들어 있다.
**확인**
```bash
sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
-o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep -i session
```
**형태**
```
"--session-store-type=redis"
"--redis-connection-url=redis://redis.keycloak-lab.svc:6379"
```
### ★ 그리고 여기서 조용한 실패를 하나 만난다
nginx 설정을 보려던 시도가 계속 **빈 결과**였다.
**실측**
```
$ sudo -n true
sudo: a password is required
```
**`test-server`(호스트)의 sudo 는 비밀번호를 요구한다.** 게스트(`kc-lab-1`/`2`)는
무암호라 A층에서 `conntrack`·`tc` 를 문제없이 썼는데, **호스트는 다르다.**
**앞선 「nginx 로그가 비어 있다」는 관측은 로그가 없던 것이 아니라 sudo 가
조용히 실패한 것이었다.** 호스트에서 무언가가 빈 결과를 주면 **먼저
`sudo -n true` 를 쳐 본다.**
## 1-6. secret 이 두 개 들어 있는가 — 값은 안 찍는다
**확인** — 키 이름만 본다
```bash
sudo kubectl -n keycloak-lab get secret oauth2-proxy-secrets \
-o jsonpath='{.data}' | tr ',' '\n' | grep -o '"[A-Z_]*"'
```
**형태**
```
"CLIENT_SECRET"
"COOKIE_SECRET_A"
"COOKIE_SECRET_B"
```
**확인** — 길이만 본다. **값은 절대 찍지 않는다**
```bash
sudo kubectl -n keycloak-lab get secret oauth2-proxy-secrets \
-o jsonpath='{.data.COOKIE_SECRET_A}' | base64 -d | wc -c
sudo kubectl -n keycloak-lab get secret oauth2-proxy-secrets \
-o jsonpath='{.data.COOKIE_SECRET_B}' | base64 -d | wc -c
```
**어디를 봐야 하는가****oauth2-proxy 는 정확히 16·24·32 바이트만 받는다.**
매니페스트의 값은 32바이트짜리다. 다른 수가 나오면 프록시가 기동에서 죽는다.
**미검증** — 원래 실행 기록에 이 명령의 출력은 없다.
**이 결과가 의미하는 것****회전 대상이 미리 두 개 준비되어 있다.**
이것이 이 실험을 「한 번 바꾸고 되돌릴 수 있는」 형태로 만든다.
## 1-7. 로그인해서 세션을 하나 만든다
**하기** — 브라우저에서
```
https://app2.hyeonworks.com/api/echo → labuser / labpass
```
**어디를 봐야 하는가** — Keycloak 로그인 화면이 뜨고, 통과하면 upstream(echo)의
JSON 이 보인다.
**이 결과가 의미하는 것** — upstream 이 받은 헤더가 그대로 찍힌다.
**실측** — [`b7-oauth2proxy-login-success.png`](../../evidence/b7-cookie-secret/b7-oauth2proxy-login-success.png)
```json
"x-forwarded-email" : [ "labuser@example.com" ],
"x-forwarded-preferred-username" : [ "labuser" ],
"x-forwarded-user" : [ "27df5ea9-8703-4ec5-badd-d972c583e1ff" ],
"x-forwarded-proto" : [ "https" ]
```
> **B-4 에서 「위조가 통한다」고 측정한 바로 그 헤더**를 oauth2-proxy 가 붙인다.
> Forward-Auth 구조의 신원 전달 방식이고, **B-4 의 결론이 그대로 적용된다** —
> edge 가 붙인 것과 공격자가 보낸 것을 upstream 은 구별하지 못한다.
## 1-8. 세션이 Redis 에 들어갔는가
**확인** — 먼저 통째로 본다
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*'
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize
```
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
```
=== 세션이 Redis 에 들어갔는가 ===
b5:pvc
_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a
dbsize: 2
```
**어디를 봐야 하는가****`dbsize` 는 2 인데 세션은 하나다.**
`b5:pvc` 는 B-5 가 남긴 키이고 이 실험과 무관하다.
**이 결과가 의미하는 것****`dbsize` 로 세션을 세면 틀린다.**
이 Redis 는 이 실험 전용이 아니다. 세션만 세려면 접두사로 좁힌다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli --scan --pattern '_oauth2_proxy-*'
```
> `KEYS` 대신 `--scan` 을 쓴다. `KEYS` 는 Redis 를 블로킹한다.
> 실험대에서는 티가 안 나지만 습관을 여기서 들인다.
## 1-9. 쿠키가 「티켓」인지 확인한다
세션 저장소를 Redis 로 옮기면 쿠키에는 **세션 전체가 아니라 티켓**만 담긴다.
**하기** — 브라우저 개발자 도구 → Application/저장소 → Cookies → `_oauth2_proxy`
**실측** — 해설 문서에 남은 값
```
쿠키: _oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMWlNall4TVRGbVltUXhabVJoWWpO...|1788500470|iPSRUlwHDB0XgC6sUdU4dq1EHq9WQDPYrDoezajKVUA=
└─ 세션 전체가 아니라 티켓이다 (약 180자)
```
**어디를 봐야 하는가**`|` 로 나뉜 **세 토막**과 전체 길이.
```
_oauth2_proxy=<ticket>|<timestamp>|<mac>
└─ Redis 키를 여기서 계산한다
```
**이 결과가 의미하는 것** — 쿠키가 짧아졌고(그래서 502 가 사라졌고),
**Redis 키 이름은 이 티켓에서 계산된다.** 4절의 「지우지 못한다」가 여기서
결정된다.
---
# 2. 주입 — secret 을 A 에서 B 로 바꾼다
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
**되돌리기**
```bash
sudo kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
-p '[{"op":"replace",
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
"value":"COOKIE_SECRET_A"}]'
```
## 2-1. 먼저 「겹칠 수 있는가」를 묻는다
**바꾸기 전에 이것부터 확인한다.** B-6 의 무중단 회전이 여기서도 되는지가
Q1 의 질문 자체이기 때문이다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/oauth2-proxy -- \
/bin/oauth2-proxy --help 2>&1 | grep cookie-secret
```
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
```
--cookie-secret string the seed string for secure cookies (optionally base64 encoded)
```
**어디를 봐야 하는가****`string`. 복수형이 아니다.**
`--cookie-secrets``--old-cookie-secret` 도 목록에 없다.
**이 결과가 의미하는 것****겹침 구간을 만들 수단이 아예 없다.**
B-6 에서 Keycloak 이 두 키를 동시에 들 수 있었던 것은 토큰 헤더에 `kid`
있어서였다. **oauth2-proxy 의 쿠키에는 그런 식별자가 없다.**
```
식별자 있음 → 읽기는 여러 key, 쓰기는 하나 → 겹침 가능 (B-6)
식별자 없음 → 전부 한 번에 바뀐다 → 겹침 불가 (B-7)
```
**이 한 줄이 이 실험의 답이다.** 나머지는 「그래서 실제로 무슨 일이 나는가」다.
## 2-2. env 배열의 어느 칸을 바꾸는지 먼저 확인한다
아래 patch 는 `env/1` 을 지목한다. **매니페스트의 순서에 달린 값이다.**
그대로 믿지 말고 확인한다.
**확인**
```bash
sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
-o jsonpath='{.spec.template.spec.containers[0].env[*].name}'; echo
```
**형태**
```
OAUTH2_PROXY_CLIENT_SECRET OAUTH2_PROXY_COOKIE_SECRET
```
**어디를 봐야 하는가**`OAUTH2_PROXY_COOKIE_SECRET`**몇 번째인가**
(0부터 센다). 위 형태에서는 두 번째이므로 `env/1` 이다. 순서가 다르면
patch 의 숫자를 고친다. **틀리면 클라이언트 비밀을 쿠키 secret 으로 덮어쓴다.**
## 2-3. 바꾼다
**하기**
```bash
date -u '+%H:%M:%S UTC 회전'
sudo kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
-p '[{"op":"replace",
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
"value":"COOKIE_SECRET_B"}]'
sudo kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
```
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
```
=== ★ secret 을 A → B 로 교체한다 ===
deployment.apps/oauth2-proxy patched
deployment "oauth2-proxy" successfully rolled out
```
**시각을 UTC 로 적어 둔다.** 프록시 로그가 UTC 이고, [B-7a](b7a-orphan-session.md)
의 정리 규칙이 **이 시각을 기준으로** 고아를 고른다.
---
# 3. 교체가 실제로 걸렸는지 확인한다
## 3-1. 지금 어느 키를 참조하는가
**확인**
```bash
sudo kubectl -n keycloak-lab get deploy oauth2-proxy \
-o jsonpath='{.spec.template.spec.containers[0].env[1].valueFrom.secretKeyRef.key}'; echo
```
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
```
현재 secret 키: COOKIE_SECRET_B
```
**어디를 봐야 하는가**`COOKIE_SECRET_B`. **Deployment 의 참조가 바뀐 것이지
Secret 의 내용이 바뀐 것이 아니다.** 두 값 다 그대로 있고 어느 쪽을 읽을지만
바뀌었다 — 그래서 되돌리기가 한 줄이다.
## 3-2. ★ 그런데 Redis 는 그대로다
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli --scan --pattern '_oauth2_proxy-*'
```
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
```
Redis 세션은 그대로인가: 2 키
```
**어디를 봐야 하는가** — 세션 수가 **회전 전과 같다.**
**이 결과가 의미하는 것****회전 자체는 아무 일도 일으키지 않는다.**
여기서 「실험 실패」라고 결론 내리면 틀린다. 무슨 일이 나려면
**누군가 옛 쿠키를 들고 와야** 한다. 그게 4절이다.
> 이 「회전만으로는 아무 일도 안 난다」를 초 단위로 확정한 것이
> [B-7a](b7a-orphan-session.md) 의 기준선이다.
## 3-3. 파드가 실제로 새로 떴는가
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -l app=oauth2-proxy -o wide
```
**어디를 봐야 하는가****파드 이름이 1-3 과 다르다.** 같으면 patch 가 아무
필드도 안 바꾼 것이다(이미 B 였거나 경로가 틀렸다). 3-1 로 돌아간다.
---
# 4. 관찰 — 옛 쿠키를 들고 가 본다
## 4-1. 브라우저로 다시 연다
**하기** — 1-7 에서 로그인한 **그 브라우저 그대로**
```
https://app2.hyeonworks.com/api/echo
```
**어디를 봐야 하는가****로그인 화면이 뜨는가.**
**실측** — 뜨지 않았다. 화면이 잠깐 깜빡이고 그대로 열린다.
**이 결과가 의미하는 것** — **Keycloak SSO 세션이 살아 있어서 조용히 재인증이
일어났다.** 쿠키는 분명히 무효가 됐는데 **사용자 눈에는 아무 일도 없었다.**
> **여기가 이 실험에서 가장 오해하기 쉬운 자리다.** 「로그인 화면이 안 떴으니
> 교체가 무중단이구나」로 읽으면 정확히 반대로 읽은 것이다. **쿠키는 죽었고,
> 사용자는 실제로 재인증을 거쳤다.** SSO 가 그 사실을 가려 준 것뿐이다.
> **IdP SSO 가 없거나 만료됐으면 전원이 로그인 화면을 본다.**
## 4-2. 로그가 무슨 일이 났는지 말한다
**확인** — 먼저 최근 로그를 그대로 본다
```bash
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=3m --prefix
```
**한 번은 통째로 본다.** 어떤 줄이 있는지 알아야 다음부터 무엇으로 걸러야 할지 안다.
`--prefix` 는 각 줄 앞에 파드 이름을 붙여 준다 — **replica 가 둘이므로 이게 없으면
누가 무엇을 했는지 못 가린다.**
이제 좁힌다.
**확인**
```bash
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=3m | grep -i stored_session
```
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
```
[2026/09/04 05:42:18] [stored_session.go:94] Error loading cookied session: session ticket cookie failed validation: <nil>, removing session
[2026/09/04 05:42:18] [stored_session.go:97] Error removing session: error decoding ticket to clear session: session ticket cookie failed validation: <nil>
```
**어디를 봐야 하는가****두 줄이 다른 말을 하고 있다.**
| 줄 | 뜻 |
|---|---|
| `stored_session.go:94` | 쿠키를 열 수 없다 → **세션을 지우겠다** |
| `stored_session.go:97` | **그 지우기가 실패했다**`error decoding ticket to clear session` |
**94 만 보고 「정리됐구나」로 읽으면 틀린다. 97 이 진짜 결과다.**
이어지는 줄이 사용자 쪽 이야기다.
**실측**
```
[2026/09/04 05:42:18] [oauthproxy.go:1024] No valid authentication in request. Initiating login.
... [AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...
```
**어디를 봐야 하는가**`Initiating login``AuthSuccess` 가 **같은 초**에 있다.
**로그인 흐름이 실제로 돌았고, 사람 손이 안 들어갔다.** 4-1 에서 화면이
깜빡였던 것이 이것이다.
## 4-3. ★ Redis 에 고아가 남는다
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli --scan --pattern '_oauth2_proxy-*'
```
**실측** — [`03-rotation.txt`](../../evidence/b7-cookie-secret/03-rotation.txt)
```
=== Redis 세션 수 (옛 세션이 남아 있는가) ===
_oauth2_proxy-978dfaefbdadccb96c7be1625dba5616
_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a
총: 2 개
```
**어디를 봐야 하는가****키가 둘이다.** 뒤엣것(`b26111f…`)은 1-8 에서 본
회전 전의 세션이고, 앞엣것이 4-1 에서 새로 생긴 것이다.
**이 결과가 의미하는 것****사용자는 하나인데 서버 세션이 둘이다.**
옛 것은 아무도 쓸 수 없고 아무도 지울 수 없다. **고아다.**
## 4-4. 왜 못 지우는가 — 티켓과 키의 관계
**무엇인가.** Redis 세션 저장소를 쓰면 쿠키에는 **티켓**만 담긴다(1-9).
티켓은 두 부분이다.
```
티켓 = <세션 ID>.<암호화 키>
│ └─ 값을 복호화할 키
└─ Redis 키 이름을 만든다 → _oauth2_proxy-<ID>
```
**왜 여기 나오나.** 티켓 전체가 cookie secret 으로 봉인되어 있다.
secret 을 바꾸면 **티켓을 열 수 없고, 그러면 세션 ID 조차 못 읽는다.**
**없거나 틀리면.** 정확히 지금 상황이다 — 프록시는 「이 세션은 못 쓴다」까지는
알지만 **「그 세션이 Redis 어디에 있다」를 모른다.** 그래서 `removing session`
을 시도하고 실패한다(4-2 의 97 번 줄).
```
secret 교체
└─ 옛 티켓을 못 푼다
├─ 사용자는 재로그인 (SSO 가 있으면 조용히)
└─ ★ 서버 세션은 TTL 만료까지 고아로 남는다
```
**로그인한 사용자 수만큼 고아가 생긴다.** 여기서 이 실험은 멈췄다.
「정말 사라지는가 · 운영자는 지울 수 있는가 · 어느 것이 고아인지 아는가」를
[B-7a](b7a-orphan-session.md) 가 이어서 잰다. **답은 「지울 수 있다」이고,
「지울 수 없다」는 oauth2-proxy 의 한계였지 Redis 의 한계가 아니었다.**
## 4-5. 덤 — replica 를 넘어도 되는 이유
로그를 파드별로 갈라 보면 BFF 와 정반대인 성질이 보인다.
**확인**
```bash
sudo kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=10m --prefix \
| grep -E 'Initiating login|AuthSuccess'
```
**실측** — 해설 문서에 남은 형태
```
--- replica 8p5hl ---
[oauthproxy.go:1024] No valid authentication in request. Initiating login.
GET "/api/echo" ← 흐름을 시작한 replica
--- replica b9928 ---
[AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...}
GET "/oauth2/callback?state=..." ← 콜백을 받은 replica
```
**어디를 봐야 하는가****시작한 파드와 콜백을 처리한 파드가 다른데 성공했다.**
**이 결과가 의미하는 것**
| | 인가 요청(state, CSRF)을 어디에 두는가 | replica 간 |
|---|---|---|
| BFF | **서버 메모리(HttpSession)** | 콜백이 다른 인스턴스로 가면 **실패** (B-0) |
| oauth2-proxy | **쿠키 (secret 으로 봉인)** | **secret 만 같으면 성공** |
**Q1 이 물은 「어떻게 공유하는가」의 답이 이것이다** — 공유할 상태가 없고,
공유할 것은 **k8s Secret 하나뿐**이다. 대신 그 하나가 단일 지점이 된다.
---
# 5. 복구
## 5-1. secret 을 A 로 되돌린다
**하기**
```bash
date -u '+%H:%M:%S UTC 되돌림'
sudo kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
-p '[{"op":"replace",
"path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key",
"value":"COOKIE_SECRET_A"}]'
sudo kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s
```
**어디를 봐야 하는가****이것도 회전이다.** B 로 만든 세션이 이번에는 고아가
된다. 되돌리기가 공짜가 아니라는 것이 이 실험의 성질 그대로다.
## 5-2. 고아를 정리한다
**확인** — 지금 몇 개 남았는지 센다
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli --scan --pattern '_oauth2_proxy-*'
```
**세 가지 선택지가 있다.**
| | 언제 | |
|---|---|---|
| 그냥 둔다 | 실험대 | TTL(1시간)이 지나면 사라진다 |
| TTL 로 골라 지운다 | 산 세션을 살리고 싶을 때 | [B-7a](b7a-orphan-session.md) 의 규칙 |
| 전부 지운다 | 어차피 다 무효일 때 | 아래 |
**하기** — 전부 지울 때. **`b5:pvc` 같은 남의 키를 같이 죽이지 않도록 패턴으로 좁힌다**
```bash
sudo kubectl -n keycloak-lab exec deploy/redis -- \
redis-cli --scan --pattern '_oauth2_proxy-*' | while read K; do
sudo kubectl -n keycloak-lab exec deploy/redis -- redis-cli del "$K"
done
```
> **`FLUSHDB` 를 쓰지 않는다.** 이 Redis 는 BFF 세션도 담고 있다(C-1 에서 확인).
> 1-8 에서 `dbsize` 가 2 였던 이유를 여기서 다시 쓴다.
## 5-3. ★ Grafana Ingress 를 되돌린다
**이것을 빠뜨리면 Grafana 가 안 열린다.**
**하기**
```bash
sudo kubectl -n keycloak-lab delete ingress oauth2-proxy
sudo kubectl apply -f ~/grafana-ingress-backup.yaml
```
**확인** — 실제로 Grafana 로 돌아갔는지 본다
```bash
sudo kubectl -n observability get ingress grafana
curl -sI https://app2.hyeonworks.com/ | head -3
```
**어디를 봐야 하는가** — Ingress 가 `observability` 에 다시 있고, `app2` 응답이
1-1 에서 본 모양으로 돌아왔는가.
> **두 Ingress 가 같은 host 를 동시에 들고 있으면 안 된다.** oauth2-proxy 것을
> **먼저 지우고** Grafana 것을 올린다. 순서를 바꾸면 어느 쪽으로 갈지가
> 컨트롤러 판단에 맡겨진다.
**oauth2-proxy 전체를 걷어내려면**
```bash
sudo kubectl delete -f deploy/lab/k8s/b7-oauth2-proxy.yaml
```
**다만 [B-7a](b7a-orphan-session.md) 와 [C-1](c1-multi-app-sso.md) 이 이 배포를
그대로 쓴다.** 이어서 할 생각이면 남겨 둔다 — 그때는 Grafana Ingress 복구도
그 실험이 끝난 뒤로 미룬다.
## 5-4. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| secret 참조 | `get deploy oauth2-proxy -o jsonpath='{...env[1]...key}'` | `COOKIE_SECRET_A` |
| 파드 | `sudo kubectl -n keycloak-lab get pods -l app=oauth2-proxy` | 둘 다 `1/1 Running` |
| Redis | `redis-cli --scan --pattern '_oauth2_proxy-*'` | 남기기로 한 만큼만 |
| Ingress (빌린 것) | `sudo kubectl -n keycloak-lab get ingress` | oauth2-proxy 것이 **없다** (걷어냈다면) |
| Ingress (Grafana) | `sudo kubectl -n observability get ingress grafana` | **있다** |
| 밖 | `curl -sI https://app2.hyeonworks.com/ \| head -3` | Grafana 로 간다 |
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이다. 지어낸 것은 없다.
| 증상 | 원인 | 확인 |
|---|---|---|
| 콜백이 `502 Bad Gateway` | **쿠키가 크다.** `Set-Cookie` 가 nginx 버퍼를 넘겼다 | Traefik 직접이 200 인지 — 1-5. Redis 세션 저장소로 옮긴다 |
| 호스트에서 nginx 설정·로그가 **빈 결과** | **`sudo` 가 조용히 실패했다** | `sudo -n true``sudo: a password is required` — 1-5 |
| `--cookie-secrets` 를 찾는데 없다 | **단수다.** 겹침 구간이 애초에 없다 | `--help \| grep cookie-secret` — 2-1 |
| patch 뒤 프록시가 기동에서 죽는다 | **env 인덱스를 잘못 짚어 클라이언트 비밀을 덮었다** | `env[*].name` 순서 확인 — 2-2 |
| secret 을 바꿨는데 Redis 가 그대로 | **정상이다.** 옛 쿠키를 들고 오는 요청이 있어야 벌어진다 | 3-2 |
| 로그인 화면이 안 떠서 「무중단」이라 읽었다 | **SSO 가 재인증을 가렸다.** 쿠키는 죽었다 | 로그의 `Initiating login` + `AuthSuccess` — 4-2 |
| 로그가 파드마다 섞여 못 읽겠다 | replica 가 둘이다 | `logs -l app=oauth2-proxy --prefix` — 4-2 |
| `dbsize` 로 세션을 셌더니 안 맞는다 | `b5:pvc` 등 다른 키가 섞인다 | `--scan --pattern '_oauth2_proxy-*'` — 1-8 |
| 파드 IP 로 `/oauth2/auth` 를 쳤더니 `HTTP 000` | **호스트에서 파드 IP 는 안 닿는다** ([`02`](../../evidence/b7-cookie-secret/02-cookie-portability.txt)) | 공개 이름으로 치거나 클러스터 안 임시 파드를 쓴다 |
| `curl` 로 OIDC 흐름을 완주하려다 실패 | 쿠키가 `HttpOnly` 이고 폼을 거쳐야 한다 | **브라우저를 쓴다** — 전제 |
| 로그 시각이 9시간 어긋난다 | **프록시 로그는 UTC** | 표시 규약의 박스 |
| `app2` 가 Grafana 로 간다 | Ingress 를 안 만들었거나 이미 복구했다 | `sudo kubectl -n keycloak-lab get ingress` |
| 실험이 끝났는데 Grafana 가 안 열린다 | **Ingress 복구를 안 했다** | 5-3 |
---
# 다음
| 실험 | B-7 이 남긴 질문 |
|---|---|
| [B-7a](b7a-orphan-session.md) 고아 세션 | **정말 사라지는가 · 지울 수 있는가 · 어느 것이 고아인지 아는가** — 셋 다 답이 나온다 |
| [C-1](c1-multi-app-sso.md) 다중 앱 SSO | **app1(BFF)과 app2(oauth2-proxy)가 준비됐다.** 서로 다른 구조로 같은 IdP 를 쓴다 |
| [D-3](../../experiment-d3-secret-management.md) 비밀 관리 | cookie secret 이 k8s Secret 에 평문이다 |
| 운영 | secret 교체는 **무중단이 아니다.** 트래픽이 적은 창을 고르고 고아를 정리한다 |