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>
11 KiB
B-7 — oauth2-proxy 의 cookie secret 을 교체하면 → Q1 미지수 7
브랜치 feature/keycloak-b7-cookie-secret-rotation ·
증거 docs/evidence/b7-cookie-secret/ ·
2026-09-04 16:00–16:15 KST
선행: B-6 — 같은 "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
계층을 분리해 원인을 좁혔다.
# 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 로 옮긴다
- --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 이 받는 것
"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 교체 — 겹침 구간이 없다
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 식별자를 저장해야 한다" 고 쓴 것의 반례다.
교체하고 관찰했다
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. 재현 절차 (명령어)
# 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 교체는 무중단이 아니다. 창을 고르고 고아를 정리한다 |
