Twelve SVG architecture diagrams cover the experiments whose documents had little or no structure drawing, embedded under a 구조 heading with a shared convention file. Seven documents carried their concepts under narrative headings and now have an explicit 개념 section so they can be found. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
288 lines
11 KiB
Markdown
288 lines
11 KiB
Markdown
# 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:00–16:15 KST
|
||
|
||
선행: [`B-6`](experiment-b6-key-rotation.md) — 같은 "key 회전" 주제의 다른 사례
|
||
|
||
**대응 질문** — Q1 미지수 7
|
||
> *"OAuth2-Proxy 구조의 replica 들이 같은 cookie secret 을 어떻게 공유하고
|
||
> 교체하게 되는가. 교체하는 동안 로그인해 있던 사람은 어떻게 되는가."*
|
||
|
||
---
|
||
|
||
## 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 이 받는 것
|
||
|
||

|
||
|
||
```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 가 살아 있어 조용히 재로그인**됐다. 로그인 화면을 안 봤다 |
|
||
| **서버 쪽 세션** | **★ 지우지 못했다** |
|
||
|
||
### 고아 세션이 남는다
|
||
|
||
```
|
||
_oauth2_proxy-978dfaefbdadccb96c7be1625dba5616 ← 새 세션
|
||
_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a ← ★ 옛 세션. 남아 있다
|
||
총: 2 개
|
||
```
|
||
|
||
**`Error removing session: error decoding ticket to clear session`**
|
||
|
||
정리하려면 **티켓에서 Redis 키를 계산해야 하는데, 그 티켓을 못 푼다.**
|
||
그래서 **지울 수도 없다.**
|
||
|
||
```
|
||
secret 교체
|
||
└─ 옛 티켓을 못 푼다
|
||
├─ 사용자는 재로그인 (SSO 가 있으면 조용히)
|
||
└─ ★ 서버 세션은 TTL 만료까지 고아로 남는다
|
||
```
|
||
|
||
**로그인한 사용자 수만큼 고아가 생긴다.** 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, 쓰기는 하나 → 겹침 가능
|
||
식별자 없음 → 전부 한 번에 바뀐다 → 겹침 불가
|
||
```
|
||
|
||
---
|
||
|
||
## 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 교체는 **무중단이 아니다.** 창을 고르고 고아를 정리한다 |
|