Files
keycloak-pattern/docs/experiment-b7-cookie-secret-rotation.md
T
DongHyeonkaandClaude Opus 5 cdac9b8178 docs: give the twelve experiments that had no architecture diagram one
An audit against the standard the series set — concepts, procedure,
commands, architecture diagram, evidence table, terminal output — found the
three new experiments met it while twelve of the original ones had no
diagram at all: A-0, A-1, A-3, A-4, A-5, A-6, A-8, B-0, B-2, B-7, C-2, D-2.

Each now has one drawn from what that experiment actually found, not filler:
A-0 shows sharing going through PostgreSQL rather than between the caches;
A-3 the gap between the 200 and the WAL flush, with both failed injections;
A-5 the three silent injection failures; A-6 the two places latency is
multiplied; B-0 the repository keyed by principal with no session id; B-2
the primary key that causes the overwrite; D-2 why the rolling update
stopped the accident halfway.

Also corrected the index's stale claim of 11 experiments without a
screenshot — it is 14, and the reason is recorded: those experiments were
measured from terminals, the database and logs, and the observability stack
does not scrape Redis, the BFF or PostgreSQL, so there is no console to
photograph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:44:36 +09:00

319 lines
12 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 — oauth2-proxy 의 cookie secret 을 교체하면 → Q1 미지수 7
브랜치 `feature/keycloak-b7-cookie-secret-rotation` ·
증거 [`docs/evidence/b7-cookie-secret/`](evidence/b7-cookie-secret/) ·
2026-09-04 16:0016:15 KST
선행: [`B-6`](experiment-b6-key-rotation.md) — 같은 "key 회전" 주제의 다른 사례
**대응 질문** — Q1 미지수 7
> *"OAuth2-Proxy 구조의 replica 들이 같은 cookie secret 을 어떻게 공유하고
> 교체하게 되는가. 교체하는 동안 로그인해 있던 사람은 어떻게 되는가."*
---
## 구조
![B-7 — 쿠키 세션의 성질과 회전의 대가](diagrams/b7-cookie-secret.svg)
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](diagrams/lab-topology.svg).
---
## 0. 결론부터
| 물음 | 답 |
|---|---|
| replica 가 secret 을 어떻게 공유하는가 | **같은 k8s Secret 을 읽는다.** 그러면 **콜백이 다른 replica 로 가도 된다** |
| 겹침 구간을 만들 수 있는가 | **★ 없다.** `--cookie-secret` 은 **단수**다 |
| 교체하면 로그인한 사람은 | **쿠키가 무효가 된다.** SSO 가 살아 있으면 재로그인이 조용히 일어난다 |
| **서버 쪽 세션은** | **★ 고아로 남는다.** 티켓을 못 풀어 지우지도 못한다 |
---
## 1. BFF 와 정반대의 성질
B-0 에서 **BFF 는 replica 2개면 로그인 자체가 실패**했다. 인가 요청이
인스턴스 메모리에 있어 콜백이 다른 인스턴스로 가면 못 찾기 때문이다.
oauth2-proxy 는 그렇지 않았다.
```
--- 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
```
**시작한 replica 와 콜백을 처리한 replica 가 다른데도 성공했다.**
| 왜 | |
|---|---|
| BFF | 인가 요청을 **HttpSession**(인스턴스 메모리)에 둔다 |
| oauth2-proxy | 인가 요청(state, CSRF)을 **쿠키**에 두고 **secret 으로 서명**한다 |
**secret 만 같으면 어느 replica 든 그 쿠키를 검증할 수 있다.**
이것이 Q1 이 물은 "어떻게 공유하는가" 의 답이다 — **공유할 상태가 없고,
공유할 것은 secret 하나뿐이다.**
---
## 2. 문제 — 큰 쿠키가 프록시를 넘지 못했다
처음 구성에서 콜백이 계속 **502** 였다.
```
GET /oauth2/callback?state=...&code=... → 502 Bad Gateway
```
**계층을 분리해 원인을 좁혔다.**
```bash
# Traefik 직접 (nginx 우회)
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 은 정상이고 nginx 가 502 를 낸다.** 그리고 502 는 **쿠키를 설정하는
응답에서만** 났다.
**진단** — oauth2-proxy 는 기본적으로 **세션 전체를 쿠키에 담는다.**
`Set-Cookie` 가 nginx 의 `proxy_buffer_size` 를 넘겼다.
> **B-4 에서 본 헤더 크기 절벽이 이번에는 응답 쪽에서 나타났다.**
> 거기서는 요청 헤더가 8KB 에서 400 이 됐고, 여기서는 응답 헤더가
> 프록시 버퍼를 넘겨 502 가 됐다. **같은 종류의 한계다.**
### 그리고 여기서 환경 사실 하나를 발견했다
nginx 설정을 보려는 시도가 계속 **빈 결과**였다.
```
$ sudo -n true
sudo: a password is required
```
**test-server 의 sudo 는 비밀번호를 요구한다.** 게스트(kc-lab-1/2)는 무암호라
A층에서 `conntrack`·`tc` 를 문제없이 썼는데, **호스트는 다르다.**
> **앞선 "nginx 로그가 비어 있다" 는 관측은 로그가 없던 것이 아니라
> sudo 가 조용히 실패한 것이었다.** A층 내내 만난 "조용한 실패" 유형이
> 도구 자체에서 또 나왔다.
### 해결 — 세션을 Redis 로 옮긴다
```yaml
- --session-store-type=redis
- --redis-connection-url=redis://redis.keycloak-lab.svc:6379
```
```
쿠키: _oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMWlNall4TVRGbVltUXhabVJoWWpO...|1788500470|iPSRUlwHDB0XgC6sUdU4dq1EHq9WQDPYrDoezajKVUA=
└─ 세션 전체가 아니라 티켓이다 (약 180자)
```
**쿠키가 티켓으로 줄자 502 가 사라졌다.**
```
Redis: _oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a
```
---
## 3. 동작 확인 — upstream 이 받는 것
![oauth2-proxy 로그인 성공](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 은 구별하지 못한다.
---
## 4. ★ secret 교체 — 겹침 구간이 없다
```bash
kubectl -n keycloak-lab exec deploy/oauth2-proxy -- /bin/oauth2-proxy --help | grep cookie-secret
```
```
--cookie-secret string the seed string for secure cookies (optionally base64 encoded)
```
**단수다.** `--cookie-secrets``--old-cookie-secret` 도 없다.
> **B-6 에서 Keycloak 이 두 키를 동시에 들고 무중단 회전을 할 수 있었던 것과
> 대비된다.** 거기서는 `kid` 로 여러 키를 구분했는데, **oauth2-proxy 의 쿠키에는
> 그런 식별자가 없다.**
>
> **key 식별자가 없으면 회전에 겹침 구간을 만들 수 없다** — B-6 에서
> "값과 함께 key 식별자를 저장해야 한다" 고 쓴 것의 반례다.
### 교체하고 관찰했다
```bash
kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
-p '[{"op":"replace","path":"/spec/.../secretKeyRef/key","value":"COOKIE_SECRET_B"}]'
```
```
[stored_session.go:94] Error loading cookied session:
session ticket cookie failed validation: <nil>, removing session
[stored_session.go:97] Error removing session:
error decoding ticket to clear session: session ticket cookie failed validation
[oauthproxy.go:1024] No valid authentication in request. Initiating login.
...
[AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...}
```
| 관찰 | |
|---|---|
| 옛 쿠키 | **검증 실패**`session ticket cookie failed validation` |
| 사용자 경험 | **Keycloak SSO 가 살아 있어 조용히 재로그인**됐다. 로그인 화면을 안 봤다 |
| **서버 쪽 세션** | **★ 지우지 못했다** → **B-7a 에서 이어받았다** |
### 고아 세션이 남는다
```
_oauth2_proxy-978dfaefbdadccb96c7be1625dba5616 ← 새 세션
_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a ← ★ 옛 세션. 남아 있다
총: 2 개
```
**`Error removing session: error decoding ticket to clear session`**
정리하려면 **티켓에서 Redis 키를 계산해야 하는데, 그 티켓을 못 푼다.**
그래서 **지울 수도 없다.**
```
secret 교체
└─ 옛 티켓을 못 푼다
├─ 사용자는 재로그인 (SSO 가 있으면 조용히)
└─ ★ 서버 세션은 TTL 만료까지 고아로 남는다
```
**로그인한 사용자 수만큼 고아가 생긴다.** TTL(여기서는 1시간)이 지나야 사라진다.
> **★ 이어짐 (B-7a)** — 여기서 멈춘 세 물음을 [B-7a](experiment-b7a-orphan-session.md) 가 잰다.
> **「지울 수 없다」는 oauth2-proxy 의 한계이지 Redis 의 한계가 아니었다.**
> · TTL 은 요청으로 갱신되지 않아 고아는 **생성 후 정확히 1시간**에 사라진다
> · `redis-cli del` 로 지워도 산 세션은 `200` — 운영자는 지울 수 있다
> · 다만 **Redis 값으로는 고아를 못 고른다.** 이름·타입·크기(3510바이트)가 같고 값은 암호화됨
> · **TTL 로 생성 시각을 역산**하면 1초 오차로 골라낼 수 있다 — 회전 시각 이전 생성분이 전부 고아다
---
## 5. Q1 미지수 7 에 대한 답
| 물음 | 답 |
|---|---|
| **어떻게 공유하는가** | 같은 k8s Secret 을 모든 replica 가 읽는다. 상태를 공유할 필요가 없다 |
| **어떻게 교체하는가** | **무중단 교체 수단이 없다.** 옵션이 단수이므로 한 번에 바뀐다 |
| **교체 중 로그인한 사람은** | 쿠키가 무효가 된다. **IdP SSO 가 살아 있으면 눈에 안 띄고, 없으면 전원 로그인 화면** |
| (추가로 드러난 것) | **서버 세션이 고아로 남는다** |
### 그래서 무엇을 해야 하는가
| | |
|---|---|
| 교체 시점 | **트래픽이 적은 시간.** 전원이 한 번씩 재인증을 거친다 |
| IdP SSO 유지 | SSO 세션이 살아 있으면 **사용자에게 안 보인다** — 이것이 실질적 완충재다 |
| 고아 정리 | TTL 에 의존하거나 **교체 전에 Redis 를 비운다** (어차피 다 무효다) |
| 근본 | 쿠키에 **key 식별자**가 있어야 겹침이 가능하다 — 지금 구현엔 없다 |
---
---
## 개념
### 상태를 어디에 두는가가 공유 문제의 성격을 정한다
| | 상태 위치 | replica 간 공유 |
|---|---|---|
| BFF | **서버 메모리 / Redis** | **저장소를 공유해야** 한다 |
| oauth2-proxy | **쿠키 (서명·암호화)** | **secret 만 같으면** 된다 |
**공유할 상태가 없으면 공유 문제도 없다.** 대신 secret 이 단일 지점이 된다.
### 세션 티켓
`--session-store-type=redis` 를 쓰면 쿠키에는 **티켓**만 담긴다.
```
_oauth2_proxy=<ticket>|<timestamp>|<mac>
└─ Redis 키를 여기서 계산한다
```
**secret 이 바뀌면 티켓을 못 푼다 → Redis 키를 계산할 수 없다 →
정리도 못 한다.** 고아 세션이 남는 이유다.
### key 식별자가 없으면 회전에 겹침이 없다
B-6 에서 Keycloak 은 `kid` 로 여러 키를 구분해 무중단 회전을 했다.
**oauth2-proxy 의 쿠키에는 그런 식별자가 없고, `--cookie-secret` 도 단수다.**
```
식별자 있음 → 읽기는 여러 key, 쓰기는 하나 → 겹침 가능
식별자 없음 → 전부 한 번에 바뀐다 → 겹침 불가
```
---
---
## 증거 파일
**증거 수집 시각: 2026-09-04 14:35 14:42 KST** (파일 mtime 기준. 문서 상단의 시각 표기는 작성 시점이라 다를 수 있다.)
| 파일 | 종류 |
|---|---|
| [`01-deploy.txt`](evidence/b7-cookie-secret/01-deploy.txt) | 터미널 원문 |
| [`02-cookie-portability.txt`](evidence/b7-cookie-secret/02-cookie-portability.txt) | 터미널 원문 |
| [`03-rotation.txt`](evidence/b7-cookie-secret/03-rotation.txt) | 터미널 원문 |
| [`b7-oauth2proxy-login-success.png`](evidence/b7-cookie-secret/b7-oauth2proxy-login-success.png) | 스크린샷 |
파일별 상세는 [`evidence/b7-cookie-secret/README.md`](evidence/b7-cookie-secret/README.md).
## 6. 재현 절차 (명령어)
```bash
# 1. 배포 (Redis 세션 저장소로 — 쿠키가 크면 프록시에서 502)
kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml
# 2. 502 가 나면 계층을 분리한다
curl -H "Host: app2.hyeonworks.com" http://<node-ip>/ping # Traefik 직접
# 3. 겹침 가능 여부 — 옵션이 단수인지 확인
kubectl -n keycloak-lab exec deploy/oauth2-proxy -- /bin/oauth2-proxy --help | grep cookie-secret
# 4. 교체
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"}]'
# 5. 무슨 일이 났는지 — 로그와 Redis 를 함께 본다
kubectl -n keycloak-lab logs deploy/oauth2-proxy | grep -i "stored_session"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy*'
```
---
## 7. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| **C-1** SSO | **app1(BFF) 과 app2(oauth2-proxy) 두 앱이 준비됐다.** 서로 다른 구조로 같은 IdP 를 쓴다 |
| **D-3** 비밀 관리 | cookie secret 이 k8s Secret 에 평문이다 |
| 운영 | secret 교체는 **무중단이 아니다.** 창을 고르고 고아를 정리한다 |