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 0000000..5750cdd Binary files /dev/null and b/b7a-live-session-after-orphan-delete.png differ diff --git a/docs/evidence/b7a-orphan-session/01-orphan-lifecycle.txt b/docs/evidence/b7a-orphan-session/01-orphan-lifecycle.txt new file mode 100644 index 0000000..4ba7a42 --- /dev/null +++ b/docs/evidence/b7a-orphan-session/01-orphan-lifecycle.txt @@ -0,0 +1,147 @@ +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: , 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 0000000..5750cdd Binary files /dev/null and b/docs/evidence/b7a-orphan-session/b7a-live-session-after-orphan-delete.png differ diff --git a/docs/experiment-b7-cookie-secret-rotation.md b/docs/experiment-b7-cookie-secret-rotation.md index 902b267..fe0ef71 100644 --- a/docs/experiment-b7-cookie-secret-rotation.md +++ b/docs/experiment-b7-cookie-secret-rotation.md @@ -172,7 +172,7 @@ kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \ |---|---| | 옛 쿠키 | **검증 실패** — `session ticket cookie failed validation` | | 사용자 경험 | **Keycloak SSO 가 살아 있어 조용히 재로그인**됐다. 로그인 화면을 안 봤다 | -| **서버 쪽 세션** | **★ 지우지 못했다** | +| **서버 쪽 세션** | **★ 지우지 못했다** → **B-7a 에서 이어받았다** | ### 고아 세션이 남는다 @@ -196,6 +196,13 @@ _oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a ← ★ 옛 세션. 남아 있 **로그인한 사용자 수만큼 고아가 생긴다.** TTL(여기서는 1시간)이 지나야 사라진다. +> **★ 이어짐 (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초 미반영** | ## 시각 자료