# 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 이 받는 것 ![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: , 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=|| └─ 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:///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 교체는 **무중단이 아니다.** 창을 고르고 고아를 정리한다 |