B-7a — cookie secret 회전이 남기는 고아 세션의 수명과 정리
=============================================================
수집: 2026-09-04 11:29 ~ 11:34 UTC · Redis + oauth2-proxy 로그 + Playwright

B-7 이 남긴 것
--------------
| 관찰 | |
|---|---|
| 옛 쿠키 | 검증 실패 — session ticket cookie failed validation |
| 사용자 경험 | Keycloak SSO 가 살아 있어 조용히 재로그인 |
| **서버 쪽 세션** | **★ 지우지 못했다** |

`Error removing session: error decoding ticket to clear session`
→ **티켓을 못 푸니 Redis 키를 계산할 수 없고, 그래서 지울 수도 없다.**

B-7 은 여기서 멈췄다. 남은 물음 셋을 잰다.
  (1) 고아의 TTL 은 실제로 줄어드는가 — 정말 사라지긴 하는가
  (2) 운영자가 직접 지울 수 있는가 · 지우면 산 세션이 다치는가
  (3) ★ 어느 키가 고아인지 구분할 수 있는가

[기준선] 회전 전 — 11:29:42 UTC
------------------------------------
  secret = COOKIE_SECRET_A
  _oauth2_proxy-f6a9201fd534a047998278452001ccbf
     type=string  ttl=3568초  크기=3510바이트
  dbsize=1

  설정: --cookie-expire=1h  --session-store-type=redis
  기동 로그: Cookie settings: name:_oauth2_proxy secure(https):true
             httponly:true expiry:1h0m0s ... refresh:disabled

[주입] 1차 회전 A → B — 11:29:56 UTC
--------------------------------------
  kubectl patch deployment oauth2-proxy ... COOKIE_SECRET_B
  회전 직후 Redis: 키 그대로 1개 (회전만으로는 아무 일도 안 일어난다)

  브라우저가 접근한 순간(11:30:27) 로그:
    [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 ...}

  → B-7 의 관찰 그대로 재현. Keycloak SSO 가 살아 있어 로그인 화면 없이 통과했다.

  Redis:
    _oauth2_proxy-87faa1c94db3bd72c11c4e100c3ca593  ttl=3588  ← 새 세션
    _oauth2_proxy-f6a9201fd534a047998278452001ccbf  ttl=3511  ← ★ 고아
    dbsize=2

[측정 1] ★ Redis 만 보고는 구분할 수 없다
-------------------------------------------
  키                                                type    strlen  ttl
  _oauth2_proxy-87faa1c9…(새)                       string   3510   3558
  _oauth2_proxy-f6a9201f…(고아)                     string   3510   3480

  · 이름 접두사가 같다 (_oauth2_proxy-)
  · 뒤는 불투명한 32자 hex — 사용자·시각·상태 어느 것도 안 담긴다
  · 타입이 같다, 크기가 **바이트 단위로 같다** (3510)
  · 값은 암호화되어 있다
      새   "\xcb\xb3h\xfa\x98\xedc\xe4@<\x9b\x83\xce\xc1\x18<…"
      고아 "N\xf5\x0e=\xe1N\xfc|\xa2qE\xde\x1b\x82k\x88\x05…"
      md5  f9ad43cc6bbb2db4  /  9b31f7c4138e6472   (다르지만 뜻을 읽을 수 없다)

  → **다른 것은 TTL 뿐이다.**

[측정 2] TTL 은 정직하게 줄어든다 — 그리고 갱신되지 않는다
-----------------------------------------------------------
  30초 간격 3회:
     t+00초  새=3557  고아=3479
     t+30초  새=3526  고아=3448
     t+60초  새=3494  고아=3417

  1초에 1초씩. 고아는 **생성 후 정확히 1시간에 사라진다.**

  요청을 보내도 늘지 않는다 (11:32:26, 11:32:49 두 번 요청 후):
     살아있는 세션 ttl=3464   ← 계속 줄어든다
  기동 로그의 `refresh:disabled` 와 일치한다. `--cookie-refresh` 가 없기 때문이다.

  ★ 이것이 다음 측정의 열쇠가 된다 — TTL 이 갱신되지 않으므로
    TTL 은 **생성 시각의 정확한 함수**다.

[측정 3] 운영자는 지울 수 있다 — 산 세션은 다치지 않는다
----------------------------------------------------------
  redis-cli del _oauth2_proxy-f6a9201f…   → 반환 1
  dbsize  2 → 1
  남은 키: _oauth2_proxy-87faa1c9…

  삭제 직후 브라우저 요청 (11:32:49):
    app2.hyeonworks.com GET - "/oauth2/userinfo" ... labuser@example.com 200 108

  → **200. 산 세션은 영향이 없다.**
    oauth2-proxy 는 못 지우지만 **운영자는 지울 수 있다.**

[측정 4] ★ 누적한다 — 회전할 때마다
-------------------------------------
  2차 회전 B → A — 11:33:27 UTC. 브라우저 재접근 후:

  키                              TTL    생성시각(추정)  판정
  _oauth2_proxy-dad9c9fb…         3581   11:33:54       살아있음
  _oauth2_proxy-87faa1c9…         3373   11:30:26       ★ 고아
  dbsize=2

  **1차 회전에서 살아남았던 세션이 2차 회전에서 고아가 됐다.**
  회전 1회 = 그 시점 로그인 사용자 수만큼의 고아.

[측정 5] ★ 그래서 정리 규칙을 유도할 수 있다
----------------------------------------------
  TTL 이 갱신되지 않으므로(측정 2):

      생성시각 = 지금 - (cookie-expire - TTL)

  이 값이 **회전 시각보다 이르면 그 키는 고아다.** 회전 이후에 만들어진
  세션은 새 secret 으로 만들어졌으므로 반드시 유효하기 때문이다.

  검증 — 추정 생성시각 11:30:26 vs 로그의 AuthSuccess 11:30:27.
  **1초 오차.** 추정이 아니라 사실상 정확하다.

  실행:
    NOW=$(date -u +%s); ROT=<회전 시각 epoch>
    redis-cli --scan --pattern '_oauth2_proxy-*' | while read K; do
      T=$(redis-cli ttl "$K")
      C=$(( NOW - (3600 - T) ))
      [ $C -lt $ROT ] && redis-cli del "$K"
    done

  실제 실행 결과: `삭제: _oauth2_proxy-87faa1c9…` · 남은 dbsize=1
  산 세션은 남고 고아만 사라졌다.

════ 결론 ════

1. **"지울 수 없다"는 oauth2-proxy 의 한계이지 Redis 의 한계가 아니다.**
   프록시는 티켓을 못 풀어 키를 계산할 수 없다. 운영자는 키를 직접 안다.

2. **고아는 반드시 사라진다 — 생성 후 1시간.** TTL 이 갱신되지 않기 때문에
   "쓰고 있으면 안 지워진다" 같은 일이 없다. 다만 그 1시간 동안은 남는다.

3. **어느 것이 고아인지는 Redis 값으로 알 수 없다.** 이름·타입·크기가
   같고 값은 암호화되어 있다. **TTL 만이 신호다.**

4. **그 TTL 로 정리 규칙이 유도된다.** 회전 시각 이전에 생성된 키는 전부
   고아다. 1초 오차로 정확히 골라낼 수 있고, 실제로 골라내 지웠다.

5. **전제가 하나 있다 — `--cookie-refresh` 를 켜면 이 규칙이 깨진다.**
   TTL 이 갱신되면 생성 시각을 역산할 수 없기 때문이다. 그때는 회전 후
   `FLUSHDB` 로 전부 지우고 모두 재인증시키는 편이 오히려 정직하다.
