Files
keycloak-pattern/docs/experiment-b7-cookie-secret-rotation.md
DongHyeonkaandClaude Opus 5 e0d27d47ce docs: correct the places where documents contradicted their own evidence
An independent audit found ten documents printing values their evidence files do not contain. C-1 printed a session count of 0 where the evidence says 4, C-2 printed a success readback for a command that exited 1, and A-1 credited the conntrack flush with a split that the timestamps attribute to a pod restart four seconds earlier.

Also measured wal_writer_delay, which A-3 had asserted as matching without ever querying it, relabelled the A-6 control that moved 41 percent, noted A-8's nine-sample resolution, corrected D-1's RTO to the 41 seconds its own timeline shows, and added a correction banner to D-2. Every experiment document now links its evidence files with their real collection times, and the duplicate screenshots are documented as duplicates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:35:49 +09:00

11 KiB
Raw Permalink Blame History

B-7 — oauth2-proxy 의 cookie secret 을 교체하면 → Q1 미지수 7

브랜치 feature/keycloak-b7-cookie-secret-rotation · 증거 docs/evidence/b7-cookie-secret/ · 2026-09-04 16:0016: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 이 받는 것

oauth2-proxy 로그인 성공

"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, 쓰기는 하나  →  겹침 가능
   식별자 없음  →  전부 한 번에 바뀐다           →  겹침 불가


증거 파일

증거 수집 시각: 2026-09-04 14:35 14:42 KST (파일 mtime 기준. 문서 상단의 시각 표기는 작성 시점이라 다를 수 있다.)

파일 종류
01-deploy.txt 터미널 원문
02-cookie-portability.txt 터미널 원문
03-rotation.txt 터미널 원문
b7-oauth2proxy-login-success.png 스크린샷

파일별 상세는 evidence/b7-cookie-secret/README.md.

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 교체는 무중단이 아니다. 창을 고르고 고아를 정리한다