From 919547a0258cafb588ec677af344f59ad2863b76 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Fri, 4 Sep 2026 20:37:11 +0900 Subject: [PATCH] =?UTF-8?q?docs(b7a):=20the=20orphan=20sessions=20can=20be?= =?UTF-8?q?=20deleted=20=E2=80=94=20oauth2-proxy=20just=20cannot=20do=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../console-2026-09-04T11-28-55-952Z.log | 1 + .../console-2026-09-04T11-30-27-075Z.log | 1 + .../console-2026-09-04T11-32-26-121Z.log | 1 + .../console-2026-09-04T11-32-49-840Z.log | 1 + .../console-2026-09-04T11-33-54-917Z.log | 2 + .../console-2026-09-04T11-34-00-240Z.log | 1 + .../page-2026-09-04T11-28-57-010Z.yml | 16 + .../page-2026-09-04T11-29-10-434Z.yml | 6 + .../page-2026-09-04T11-32-26-282Z.yml | 1 + .../page-2026-09-04T11-32-49-912Z.yml | 1 + .../page-2026-09-04T11-33-54-983Z.yml | 1 + b7a-live-session-after-orphan-delete.png | Bin 0 -> 17965 bytes .../01-orphan-lifecycle.txt | 147 +++++++++ docs/evidence/b7a-orphan-session/README.md | 17 + .../b7a-live-session-after-orphan-delete.png | Bin 0 -> 17965 bytes docs/experiment-b7-cookie-secret-rotation.md | 9 +- docs/experiment-b7a-orphan-session.md | 293 ++++++++++++++++++ docs/experiment-index.md | 1 + 18 files changed, 498 insertions(+), 1 deletion(-) create mode 100644 .playwright-mcp/console-2026-09-04T11-28-55-952Z.log create mode 100644 .playwright-mcp/console-2026-09-04T11-30-27-075Z.log create mode 100644 .playwright-mcp/console-2026-09-04T11-32-26-121Z.log create mode 100644 .playwright-mcp/console-2026-09-04T11-32-49-840Z.log create mode 100644 .playwright-mcp/console-2026-09-04T11-33-54-917Z.log create mode 100644 .playwright-mcp/console-2026-09-04T11-34-00-240Z.log create mode 100644 .playwright-mcp/page-2026-09-04T11-28-57-010Z.yml create mode 100644 .playwright-mcp/page-2026-09-04T11-29-10-434Z.yml create mode 100644 .playwright-mcp/page-2026-09-04T11-32-26-282Z.yml create mode 100644 .playwright-mcp/page-2026-09-04T11-32-49-912Z.yml create mode 100644 .playwright-mcp/page-2026-09-04T11-33-54-983Z.yml create mode 100644 b7a-live-session-after-orphan-delete.png create mode 100644 docs/evidence/b7a-orphan-session/01-orphan-lifecycle.txt create mode 100644 docs/evidence/b7a-orphan-session/README.md create mode 100644 docs/evidence/b7a-orphan-session/b7a-live-session-after-orphan-delete.png create mode 100644 docs/experiment-b7a-orphan-session.md diff --git a/.playwright-mcp/console-2026-09-04T11-28-55-952Z.log b/.playwright-mcp/console-2026-09-04T11-28-55-952Z.log new file mode 100644 index 0000000..62aa5db --- /dev/null +++ b/.playwright-mcp/console-2026-09-04T11-28-55-952Z.log @@ -0,0 +1 @@ +[ 14441ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ chrome-error://chromewebdata/:0 diff --git a/.playwright-mcp/console-2026-09-04T11-30-27-075Z.log b/.playwright-mcp/console-2026-09-04T11-30-27-075Z.log new file mode 100644 index 0000000..6b1fb16 --- /dev/null +++ b/.playwright-mcp/console-2026-09-04T11-30-27-075Z.log @@ -0,0 +1 @@ +[ 296ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ chrome-error://chromewebdata/:0 diff --git a/.playwright-mcp/console-2026-09-04T11-32-26-121Z.log b/.playwright-mcp/console-2026-09-04T11-32-26-121Z.log new file mode 100644 index 0000000..79038ac --- /dev/null +++ b/.playwright-mcp/console-2026-09-04T11-32-26-121Z.log @@ -0,0 +1 @@ +[ 158ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/favicon.ico:0 diff --git a/.playwright-mcp/console-2026-09-04T11-32-49-840Z.log b/.playwright-mcp/console-2026-09-04T11-32-49-840Z.log new file mode 100644 index 0000000..a7e3901 --- /dev/null +++ b/.playwright-mcp/console-2026-09-04T11-32-49-840Z.log @@ -0,0 +1 @@ +[ 67ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/favicon.ico:0 diff --git a/.playwright-mcp/console-2026-09-04T11-33-54-917Z.log b/.playwright-mcp/console-2026-09-04T11-33-54-917Z.log new file mode 100644 index 0000000..f191f3b --- /dev/null +++ b/.playwright-mcp/console-2026-09-04T11-33-54-917Z.log @@ -0,0 +1,2 @@ +[ 40ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/oauth2/userinfo:0 +[ 162ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/favicon.ico:0 diff --git a/.playwright-mcp/console-2026-09-04T11-34-00-240Z.log b/.playwright-mcp/console-2026-09-04T11-34-00-240Z.log new file mode 100644 index 0000000..675d936 --- /dev/null +++ b/.playwright-mcp/console-2026-09-04T11-34-00-240Z.log @@ -0,0 +1 @@ +[ 117ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ chrome-error://chromewebdata/:0 diff --git a/.playwright-mcp/page-2026-09-04T11-28-57-010Z.yml b/.playwright-mcp/page-2026-09-04T11-28-57-010Z.yml new file mode 100644 index 0000000..d49d322 --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T11-28-57-010Z.yml @@ -0,0 +1,16 @@ +- generic [ref=e3]: + - banner [ref=e4]: + - generic [ref=e5]: keycloak-patterns + - main [ref=e6]: + - heading "Sign in to your account" [level=1] [ref=e8] + - generic [ref=e12]: + - generic [ref=e13]: + - generic [ref=e14]: Username or email + - textbox "Username or email" [active] [ref=e17] + - generic [ref=e18]: + - generic [ref=e19]: Password + - generic [ref=e21]: + - textbox "Password" [ref=e24] + - button "Show password" [ref=e26] [cursor=pointer]: + - generic [aria-hidden] [ref=e27]:  + - button "Sign In" [ref=e30] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-04T11-29-10-434Z.yml b/.playwright-mcp/page-2026-09-04T11-29-10-434Z.yml new file mode 100644 index 0000000..4adcb7e --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T11-29-10-434Z.yml @@ -0,0 +1,6 @@ +- generic [ref=f1e3]: + - generic [ref=f1e6]: + - heading "This page isn’t working" [level=1] [ref=f1e7] + - paragraph [ref=f1e8]: If the problem continues, contact the site owner. + - generic [ref=f1e9]: HTTP ERROR 401 + - button "Reload" [ref=f1e12] [cursor=pointer] \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-04T11-32-26-282Z.yml b/.playwright-mcp/page-2026-09-04T11-32-26-282Z.yml new file mode 100644 index 0000000..46ad702 --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T11-32-26-282Z.yml @@ -0,0 +1 @@ +- generic [active] [ref=f3e1]: "{\"user\":\"27df5ea9-8703-4ec5-badd-d972c583e1ff\",\"email\":\"labuser@example.com\",\"preferredUsername\":\"labuser\"}" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-04T11-32-49-912Z.yml b/.playwright-mcp/page-2026-09-04T11-32-49-912Z.yml new file mode 100644 index 0000000..f534d2e --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T11-32-49-912Z.yml @@ -0,0 +1 @@ +- generic [active] [ref=f4e1]: "{\"user\":\"27df5ea9-8703-4ec5-badd-d972c583e1ff\",\"email\":\"labuser@example.com\",\"preferredUsername\":\"labuser\"}" \ No newline at end of file diff --git a/.playwright-mcp/page-2026-09-04T11-33-54-983Z.yml b/.playwright-mcp/page-2026-09-04T11-33-54-983Z.yml new file mode 100644 index 0000000..dd946ee --- /dev/null +++ b/.playwright-mcp/page-2026-09-04T11-33-54-983Z.yml @@ -0,0 +1 @@ +- generic [active] [ref=f5e1]: Unauthorized \ No newline at end of file diff --git a/b7a-live-session-after-orphan-delete.png b/b7a-live-session-after-orphan-delete.png new file mode 100644 index 0000000000000000000000000000000000000000..5750cdd3ce15b6d4153f3a8e4d7942196e2fff5c GIT binary patch literal 17965 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, 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` 로 전부 지우고 모두 재인증시키는 편이 오히려 정직하다. diff --git a/docs/evidence/b7a-orphan-session/README.md b/docs/evidence/b7a-orphan-session/README.md new file mode 100644 index 0000000..b0a8ff9 --- /dev/null +++ b/docs/evidence/b7a-orphan-session/README.md @@ -0,0 +1,17 @@ +# B-7a — 고아 세션의 수명과 정리 증거 + +2026-09-04 11:29 – 11:34 UTC +해설: [`docs/experiment-b7a-orphan-session.md`](../../experiment-b7a-orphan-session.md) + +| 파일 | 무엇을 보여주는가 | +|---|---| +| `01-orphan-lifecycle.txt` | 회전 2회로 고아가 **누적**하는 것 · TTL 이 1초/초로 줄고 **요청으로 갱신되지 않는 것** · Redis 만으로는 **구분 불가**(이름·타입·크기 동일, 값 암호화) · `redis-cli del` 로 지워도 산 세션은 **200** · TTL 역산 정리 규칙이 **1초 오차**로 맞는 것 | +| `b7a-live-session-after-orphan-delete.png` | 고아를 지운 직후 살아있는 세션이 `/oauth2/userinfo` 를 정상 응답하는 브라우저 화면 | + +## 핵심 다섯 줄 + +1. **「지울 수 없다」는 oauth2-proxy 의 한계이지 Redis 의 한계가 아니다.** 프록시는 티켓을 못 풀어 키를 계산 못 한다. 운영자는 키를 직접 안다 — `del` 반환 1, dbsize 2→1, 산 세션은 그대로 200. +2. **고아는 반드시 사라진다 — 생성 후 정확히 1시간.** TTL 이 요청으로 갱신되지 않기 때문이다(`refresh:disabled`). 다만 그 1시간은 남는다. +3. **회전할 때마다 누적한다.** 1차 회전을 살아남은 세션이 2차 회전에서 고아가 됐다. 회전 1회 = 그 시점 로그인 사용자 수만큼. +4. **Redis 값으로는 고아를 못 고른다.** 이름 접두사·타입·크기(3510바이트)가 같고 값은 암호화되어 있다. **TTL 만이 신호다.** +5. **그 TTL 로 정리 규칙이 유도된다.** `생성시각 = 지금 − (cookie-expire − TTL)` 이 회전 시각보다 이르면 고아다. 추정 11:30:26 대 로그 11:30:27 — **1초 오차**. 실제로 골라 지웠고 산 세션만 남았다. diff --git a/docs/evidence/b7a-orphan-session/b7a-live-session-after-orphan-delete.png b/docs/evidence/b7a-orphan-session/b7a-live-session-after-orphan-delete.png new file mode 100644 index 0000000000000000000000000000000000000000..5750cdd3ce15b6d4153f3a8e4d7942196e2fff5c GIT binary patch literal 17965 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 **★ 이어짐 (B-7a)** — 여기서 멈춘 세 물음을 [B-7a](experiment-b7a-orphan-session.md) 가 잰다. +> **「지울 수 없다」는 oauth2-proxy 의 한계이지 Redis 의 한계가 아니었다.** +> · TTL 은 요청으로 갱신되지 않아 고아는 **생성 후 정확히 1시간**에 사라진다 +> · `redis-cli del` 로 지워도 산 세션은 `200` — 운영자는 지울 수 있다 +> · 다만 **Redis 값으로는 고아를 못 고른다.** 이름·타입·크기(3510바이트)가 같고 값은 암호화됨 +> · **TTL 로 생성 시각을 역산**하면 1초 오차로 골라낼 수 있다 — 회전 시각 이전 생성분이 전부 고아다 + --- ## 5. Q1 미지수 7 에 대한 답 diff --git a/docs/experiment-b7a-orphan-session.md b/docs/experiment-b7a-orphan-session.md new file mode 100644 index 0000000..e806726 --- /dev/null +++ b/docs/experiment-b7a-orphan-session.md @@ -0,0 +1,293 @@ +# B-7a — 고아 세션은 지울 수 있는가 + +브랜치 `feature/keycloak-b7a-orphan-session` · +증거 [`docs/evidence/b7a-orphan-session/`](evidence/b7a-orphan-session/) · +2026-09-04 20:29–20: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- +``` + +**왜 여기 나오나.** 티켓 전체가 cookie secret 으로 서명·암호화되어 있다. +secret 을 바꾸면 **티켓을 열 수 없고, 그러면 세션 ID 조차 못 읽는다.** +값을 못 읽는 게 아니라 **어느 키를 지워야 하는지를 모른다.** + +**없거나 틀리면.** 정확히 지금 상황이다 — 프록시는 "이 세션은 못 쓴다"까지는 +알지만 "그 세션이 Redis 어디에 있다"를 모른다. 그래서 `removing session` 을 +시도하고 실패한다. + +**확인.** +```bash +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) + +```bash +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|\xa2qE\xde…` | + +**바이트 단위로 크기가 같다.** 이름 뒤쪽은 불투명한 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. 운영자는 지울 수 있다 + +```bash +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. 산 세션은 다치지 않는다.** + +![고아 삭제 후 살아있는 세션](evidence/b7a-orphan-session/b7a-live-session-after-orphan-delete.png) + +> **"지울 수 없다"는 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초 오차.** 추정이 아니라 사실상 정확하다. + +```bash +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. 재현 절차 (명령어) + +```bash +# ── 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`](evidence/b7a-orphan-session/01-orphan-lifecycle.txt) | 터미널 | 회전 2회 · TTL 추이 · 구분 불가 근거 · 삭제 후 `200` · 정리 규칙 검증 | +| [`b7a-live-session-after-orphan-delete.png`](evidence/b7a-orphan-session/b7a-live-session-after-orphan-delete.png) | 스크린샷 | 고아 삭제 직후 살아있는 세션의 응답 | + +파일별 상세는 [`evidence/b7a-orphan-session/README.md`](evidence/b7a-orphan-session/README.md). diff --git a/docs/experiment-index.md b/docs/experiment-index.md index 1e34a08..d01da72 100644 --- a/docs/experiment-index.md +++ b/docs/experiment-index.md @@ -28,6 +28,7 @@ | **D-3** | 비밀 관리 | `...d3-secret-management` | **RBAC 만 실제로 감춘다** | | **D-4** | 인증서 갱신 | `...d4-certificate-renewal` | **갱신은 됐는데 36분 39초 반영 안 됨** (훅 3경로 전부 비었음). reload 자체는 **무중단**(8856건 0실패) | | **A-7a** | volatile refresh 500 원인 확정 | `...a7a-volatile-cause` | **가설(`REVOKED_TOKEN`)은 틀렸다** — `CLIENT_SCOPE_CLIENT` 조회다. **A-7 의 표는 캐시 온도에 따라 400/500/200** | +| **B-7a** | 고아 세션 정리 | `...b7a-orphan-session` | **지울 수 있다** — 프록시가 못 할 뿐. TTL 역산으로 고아만 **1초 오차**로 골라낸다 | | **후속** | 미측정 3항목 채우기 | `...followup-untested-items` | **셋 다 완료.** 정방향 업그레이드 무중단 · **롤백 불가는 조건부였다** · role 변경은 요청으로 반영 안 됨 · **D-4 갱신 36분 39초 미반영** | ## 시각 자료