Files
keycloak-pattern/docs/experiment-b7a-orphan-session.md
T
DongHyeonkaandClaude Opus 5 919547a025 docs(b7a): the orphan sessions can be deleted — oauth2-proxy just cannot do it
B-7 stopped at "could not delete the server-side session". The reason it
gave was right: the ticket carries the session id, the ticket is encrypted
with the cookie secret, so after a rotation the proxy cannot work out which
Redis key to remove. But that is a limitation of the proxy, not of Redis.

Measured across two rotations:

- The orphan does expire. TTL falls one second per second and is not
  refreshed by requests (the startup log says refresh:disabled, and
  --cookie-refresh is unset), so it dies exactly one hour after creation.
- An operator can delete it. `redis-cli del` returned 1, dbsize went 2 to 1,
  and the live session answered /oauth2/userinfo with 200 immediately after.
- But nothing in Redis says which key is the orphan. Same name prefix, same
  type, the same 3510 bytes, and the values are encrypted.
- TTL is the only signal, and because it is never refreshed it is an exact
  function of creation time. Anything created before the rotation is an
  orphan. Derived creation time 11:30:26 against the AuthSuccess log line at
  11:30:27 — one second out. The rule was then run and removed the orphan
  while leaving the live session.
- They accumulate: the session that survived the first rotation became the
  orphan of the second.

The caveat is recorded too: turning on --cookie-refresh breaks the
derivation, and at that point flushing and forcing everyone to
re-authenticate is the more honest option.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 20:37:11 +09:00

11 KiB
Raw Blame History

B-7a — 고아 세션은 지울 수 있는가

브랜치 feature/keycloak-b7a-orphan-session · 증거 docs/evidence/b7a-orphan-session/ · 2026-09-04 20:2920:34 KST

B-7 이 「★ 지우지 못했다」 로 남긴 자리를 잰다. 결론부터 — oauth2-proxy 가 못 지우는 것이지, 지울 수 없는 것이 아니다.


0. 결론부터

물음
고아는 정말 사라지는가 사라진다. 생성 후 정확히 1시간. TTL 이 갱신되지 않는다
운영자가 지울 수 있는가 지울 수 있다. redis-cli del 로 지워도 산 세션은 200
어느 것이 고아인지 아는가 Redis 값으로는 모른다. 이름·타입·크기가 같고 값은 암호화됨
그럼 어떻게 고르는가 ★ TTL 로 생성 시각을 역산한다. 1초 오차로 맞는다
회전할 때마다 누적하는가 누적한다. 회전 1회 = 그 시점 로그인 사용자 수

1. B-7 이 어디서 멈췄나

[stored_session.go:97] Error removing session:
    error decoding ticket to clear session: session ticket cookie failed validation

티켓을 못 푸니 Redis 키를 계산할 수 없고, 그래서 지울 수도 없다.

개념 — 티켓과 키의 관계

무엇인가. Redis 세션 저장소를 쓰면 쿠키에는 세션 전체가 아니라 티켓(ticket) 만 담긴다. 티켓은 두 부분이다.

   티켓 = <세션 ID>.<암호화 키>
            │            └─ 값을 복호화할 키
            └─ Redis 키 이름을 만든다 → _oauth2_proxy-<ID>

왜 여기 나오나. 티켓 전체가 cookie secret 으로 서명·암호화되어 있다. secret 을 바꾸면 티켓을 열 수 없고, 그러면 세션 ID 조차 못 읽는다. 값을 못 읽는 게 아니라 어느 키를 지워야 하는지를 모른다.

없거나 틀리면. 정확히 지금 상황이다 — 프록시는 "이 세션은 못 쓴다"까지는 알지만 "그 세션이 Redis 어디에 있다"를 모른다. 그래서 removing session 을 시도하고 실패한다.

확인.

kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=2m | grep stored_session

2. 재현 — 회전이 고아를 만드는 순간

기준선 (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

주입 — 1차 회전 A → B (11:29:56 UTC)

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"}]'

회전 직후 Redis 는 그대로 1개다. 회전 자체는 아무 일도 일으키지 않는다 — 누군가 옛 쿠키를 들고 오는 순간에 비로소 벌어진다.

브라우저가 접근한 11:30:27:

[stored_session.go:94] Error loading cookied session: … removing session
[stored_session.go:97] Error removing session: error decoding ticket to clear session
[oauthproxy.go:1024] No valid authentication in request. Initiating login.
[AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com …}
_oauth2_proxy-87faa1c94db3bd72c11c4e100c3ca593  ttl=3588  ← 새 세션
_oauth2_proxy-f6a9201fd534a047998278452001ccbf  ttl=3511  ← ★ 고아

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


3. ★ Redis 만 보고는 구분할 수 없다

새 세션 고아
이름 _oauth2_proxy-87faa1c9… _oauth2_proxy-f6a9201f…
type string string
크기 3510바이트 3510바이트
\xcb\xb3h\xfa\x98\xedc\xe4@<… `N\xf5\x0e=\xe1N\xfc

바이트 단위로 크기가 같다. 이름 뒤쪽은 불투명한 32자 hex 이고 사용자도 시각도 상태도 담지 않는다. 값은 암호화되어 있어 뜻을 읽을 수 없다.

다른 것은 TTL 하나뿐이다. 이것이 4절의 열쇠가 된다.


4. TTL 은 정직하고, 갱신되지 않는다

30초 간격 3회:

새 세션 고아
t+00초 3557 3479
t+30초 3526 3448
t+60초 3494 3417

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

요청을 두 번 보낸 뒤에도 산 세션의 TTL 은 3464 로 계속 줄었다. 기동 로그의 refresh:disabled 와 일치한다 — --cookie-refresh 가 없다.

개념 — TTL 갱신 여부가 왜 중요한가

무엇인가. --cookie-refresh 를 켜면 요청마다 세션이 갱신되고 TTL 이 연장된다. 끄면 생성 시점부터 고정된 시간이 흐른다.

왜 여기 나오나. TTL 이 고정이면 TTL 은 생성 시각의 정확한 함수다.

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

이 한 줄이 5절의 정리 규칙 전체를 만든다.

없거나 틀리면. --cookie-refresh 를 켜는 순간 이 역산이 무너진다. 그때는 고아를 골라낼 수단이 사라지고, 회전 후 FLUSHDB 로 전부 지워 모두 재인증시키는 편이 오히려 정직하다.


5. 운영자는 지울 수 있다

redis-cli del _oauth2_proxy-f6a9201f…     # 반환 1
dbsize  2 → 1
남은 키: _oauth2_proxy-87faa1c9…

삭제 직후 브라우저 요청:

app2.hyeonworks.com GET - "/oauth2/userinfo" … labuser@example.com 200 108

200. 산 세션은 다치지 않는다.

고아 삭제 후 살아있는 세션

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


6. 누적한다 — 회전할 때마다

2차 회전 B → A (11:33:27) 후 브라우저 재접근:

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

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


7. ★ 그래서 정리 규칙이 유도된다

TTL 이 갱신되지 않으므로(4절), 생성 시각을 역산할 수 있다. 그 값이 회전 시각보다 이르면 고아다 — 회전 이후에 만들어진 세션은 새 secret 으로 만들어졌으므로 반드시 유효하기 때문이다.

정확도 검증 — 역산 11:30:26 대 로그의 AuthSuccess 11:30:27. 1초 오차. 추정이 아니라 사실상 정확하다.

NOW=$(date -u +%s)
ROT=<회전 시각 epoch>          # date -u -d '2026-09-04 11:33:27' +%s
EXP=3600                       # --cookie-expire 를 초로

kubectl -n keycloak-lab exec deploy/redis -- \
  redis-cli --scan --pattern '_oauth2_proxy-*' | while read K; do
    T=$(kubectl -n keycloak-lab exec deploy/redis -- redis-cli ttl "$K")
    C=$(( NOW - (EXP - T) ))
    [ $C -lt $ROT ] && kubectl -n keycloak-lab exec deploy/redis -- redis-cli del "$K"
  done

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


8. Q1 미지수 7 에 남기는 보완

B-7 은 "고아가 남는다" 까지 답했다. B-7a 가 덧붙이는 것:

얼마나 남는가 1시간. TTL 이 갱신되지 않으므로 무한정 쌓이지 않는다
지울 수 있는가 있다. 프록시가 못 할 뿐이다
어떻게 고르는가 TTL 역산. 회전 시각 이전 생성분이 전부 고아다
전제 --cookie-refresh 를 켜면 이 규칙이 깨진다. 그때는 FLUSHDB 가 정직하다

secret 회전의 진짜 비용은 "재로그인"이 아니라 "저장소에 남는 것"이다. 그리고 그 비용은 저장소를 쿠키로 쓰면 0 이다 — 지울 서버 상태가 애초에 없기 때문이다. B-7 이 Redis 로 옮긴 대가가 여기서 청구된다.


9. 재현 절차 (명령어)

# ── 0. app2 를 oauth2-proxy 로 잠시 빌린다 (Grafana 를 되돌릴 것)
kubectl -n observability get ingress grafana -o yaml > /tmp/grafana-ingress-backup.yaml
kubectl -n observability delete ingress grafana
kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml   # Ingress 포함

# ── 1. 로그인해서 세션을 만든다 (브라우저 필요 — 쿠키가 HttpOnly 다)
#      https://app2.hyeonworks.com/ → labuser / labpass

# ── 2. 기준선
R() { kubectl -n keycloak-lab exec deploy/redis -- redis-cli "$@"; }
R --scan --pattern '_oauth2_proxy-*' | while read K; do
  echo "$K type=$(R type $K) ttl=$(R ttl $K) len=$(R strlen $K)"
done

# ── 3. 회전. ★ 회전 시각을 반드시 기록한다 — 7절 규칙이 이걸 쓴다
ROT=$(date -u +%s); echo "회전 $ROT ($(date -u -d @$ROT +%H:%M:%S))"
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"}]'
kubectl -n keycloak-lab rollout status deployment/oauth2-proxy --timeout=180s

# ── 4. 브라우저로 다시 접근 → 이때 고아가 생긴다
#      로그에서 확인:
kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=2m | grep stored_session

# ── 5. TTL 이 갱신되지 않는지 확인 (요청을 보낸 뒤에도 줄어야 한다)
for i in 1 2 3; do R --scan --pattern '_oauth2_proxy-*' | while read K; do
  printf "%s ttl=%s\n" "$K" "$(R ttl $K)"; done; sleep 30; done

# ── 6. 정리 — 회전 시각 이전 생성분이 고아다
NOW=$(date -u +%s); EXP=3600
R --scan --pattern '_oauth2_proxy-*' | while read K; do
  T=$(R ttl "$K"); C=$(( NOW - (EXP - T) ))
  if [ $C -lt $ROT ]; then echo "삭제 $K (생성 $(date -u -d @$C +%H:%M:%S))"; R del "$K"; fi
done

# ── 7. 복구
kubectl -n keycloak-lab delete ingress oauth2-proxy
kubectl apply -f /tmp/grafana-ingress-backup.yaml
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_A"}]'

증거 파일

파일 종류 무엇을 보여주는가
01-orphan-lifecycle.txt 터미널 회전 2회 · TTL 추이 · 구분 불가 근거 · 삭제 후 200 · 정리 규칙 검증
b7a-live-session-after-orphan-delete.png 스크린샷 고아 삭제 직후 살아있는 세션의 응답

파일별 상세는 evidence/b7a-orphan-session/README.md.