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>
767 lines
32 KiB
Markdown
767 lines
32 KiB
Markdown
# 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:35–14:42 KST 수집 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
|
||
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 값은 당신 것과 다르다 |
|
||
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행 기록에 이 명령의 출력은 없다 |
|
||
|
||
> **시계에 주의한다.** `kubectl` 로 보는 시각은 KST 인데 **oauth2-proxy 가 찍는
|
||
> 로그 타임스탬프는 UTC 다.** 증거의 로그가 `[2026/09/04 05:41:46]` 인 것과
|
||
> 수집 시각이 `14:35–14: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 교체는 **무중단이 아니다.** 트래픽이 적은 창을 고르고 고아를 정리한다 |
|