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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b9f4ef7bc2
commit
919547a025
@@ -0,0 +1 @@
|
||||
[ 14441ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ chrome-error://chromewebdata/:0
|
||||
@@ -0,0 +1 @@
|
||||
[ 296ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ chrome-error://chromewebdata/:0
|
||||
@@ -0,0 +1 @@
|
||||
[ 158ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/favicon.ico:0
|
||||
@@ -0,0 +1 @@
|
||||
[ 67ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/favicon.ico:0
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
[ 117ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ chrome-error://chromewebdata/:0
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -0,0 +1 @@
|
||||
- generic [active] [ref=f3e1]: "{\"user\":\"27df5ea9-8703-4ec5-badd-d972c583e1ff\",\"email\":\"labuser@example.com\",\"preferredUsername\":\"labuser\"}"
|
||||
@@ -0,0 +1 @@
|
||||
- generic [active] [ref=f4e1]: "{\"user\":\"27df5ea9-8703-4ec5-badd-d972c583e1ff\",\"email\":\"labuser@example.com\",\"preferredUsername\":\"labuser\"}"
|
||||
@@ -0,0 +1 @@
|
||||
- generic [active] [ref=f5e1]: Unauthorized
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -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: <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` 로 전부 지우고 모두 재인증시키는 편이 오히려 정직하다.
|
||||
@@ -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초 오차**. 실제로 골라 지웠고 산 세션만 남았다.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -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 에 대한 답
|
||||
|
||||
@@ -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-<ID>
|
||||
```
|
||||
|
||||
**왜 여기 나오나.** 티켓 전체가 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. 산 세션은 다치지 않는다.**
|
||||
|
||||

|
||||
|
||||
> **"지울 수 없다"는 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).
|
||||
@@ -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초 미반영** |
|
||||
|
||||
## 시각 자료
|
||||
|
||||
Reference in New Issue
Block a user