docs: B-3 — concurrent refresh does not lose a race, it destroys the session
Five simultaneous refreshes with one token return a single 200, and that winner's new token is already dead. Reuse detection removes the client session while the user session stays, which is why the other responses read Session doesn't have required client rather than a reuse error. Comparing policies shows rotation off passes all five and keeps the session, while raising refreshTokenMaxReuse to one still destroys it. Since no retry can recover a removed client session, Q2's own criterion resolves to a lock, and a database row lock is the natural place because its lifetime is tied to the connection. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
711878379c
commit
b16e1dccf7
@@ -0,0 +1,270 @@
|
||||
# B-3 — 같은 refresh token 으로 동시에 갱신하면 → Q2
|
||||
|
||||
브랜치 `feature/keycloak-b3-refresh-token-contention` ·
|
||||
증거 [`docs/evidence/b3-refresh-contention/`](evidence/b3-refresh-contention/) ·
|
||||
2026-09-04 15:15–15:25 KST
|
||||
|
||||
선행: [`B-2`](experiment-b2-multi-instance-session.md) — 토큰이 공유되어야 경쟁이 성립한다
|
||||
|
||||
**대응 질문** — [Q2 · Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가](https://hyeonworks.com/questions/refresh-rotation-replica-contention)
|
||||
|
||||
> Q2 가 남긴 것: *"실제 Keycloak 응답과 session 영향은 아직 재현해 보지 않았다."*
|
||||
|
||||
---
|
||||
|
||||
## 0. 결론부터
|
||||
|
||||
**"하나는 성공하고 하나는 실패한다"가 아니다. 세션이 파괴된다.**
|
||||
|
||||
```
|
||||
5개를 동시에 보냈을 때 (rotation ON, maxReuse=0)
|
||||
|
||||
요청 1: 400 "Maximum allowed refresh token reuse exceeded"
|
||||
요청 2: 400 "Session doesn't have required client"
|
||||
요청 3: 400 "Session doesn't have required client"
|
||||
요청 4: 400 "Session doesn't have required client"
|
||||
요청 5: 200 (토큰 발급됨)
|
||||
|
||||
★ 그런데 5번이 받은 토큰으로 다시 갱신하면 → 400
|
||||
```
|
||||
|
||||
**이긴 요청조차 쓸 수 없는 토큰을 받는다.**
|
||||
|
||||
| 구성 | 성공 | 이긴 토큰 재사용 | client_session |
|
||||
|---|---|---|---|
|
||||
| **A** rotation ON · maxReuse=0 | **1 / 5** | **400** | **0 — 파괴** |
|
||||
| **B** rotation OFF | **5 / 5** | 200 | **1 — 생존** |
|
||||
| **C** rotation ON · maxReuse=1 | **2 / 5** | **400** | **0 — 파괴** |
|
||||
|
||||
**Q2 의 판정 기준** — *"실패가 사용자에게 노출되면 lock, 노출되지 않으면 재시도."*
|
||||
**재시도로 회복되지 않는다.** 세션 자체가 없어지므로 답은 **lock** 이다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 전제를 Q2 에 맞춘다
|
||||
|
||||
```bash
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh get realms/keycloak-patterns \
|
||||
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
|
||||
```
|
||||
|
||||
```json
|
||||
{ "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
|
||||
```
|
||||
|
||||
**기본값은 rotation 이 꺼져 있었다.** Q2 는 *"realm 이 refresh token rotation 과
|
||||
재사용 허용 0회를 쓰게 되어서"* 를 전제로 하므로 맞춰야 한다.
|
||||
|
||||
```bash
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=0
|
||||
```
|
||||
|
||||
> **`revokeRefreshToken` 이 rotation 스위치다.** 이름이 "회전"이 아니라
|
||||
> "취소"인 것이 헷갈리는데, **켜면 새 토큰을 줄 때 옛 토큰을 무효화**한다.
|
||||
> `refreshTokenMaxReuse` 는 그 위에서 **몇 번까지 봐줄 것인가**이다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 재현 — 진짜 동시성을 만든다
|
||||
|
||||
B-2 에서 토큰이 PostgreSQL 로 공유되므로 두 replica 가 같은 항목을 본다.
|
||||
다만 **Keycloak 쪽 동작을 분리해서 보려면** BFF 를 거치지 않는 편이 낫다.
|
||||
|
||||
```bash
|
||||
# 파드 안에서 5개를 동시에 띄우고 wait
|
||||
i=1; while [ $i -le 5 ]; do
|
||||
( curl -s -o /tmp/b$i -w "%{http_code}" -X POST $KC \
|
||||
-d grant_type=refresh_token -d client_id=bff-confidential \
|
||||
-d client_secret=bff-lab-secret -d refresh_token=$RT > /tmp/c$i ) &
|
||||
i=$((i+1)); done
|
||||
wait
|
||||
```
|
||||
|
||||
**순차 실행이면 재현되지 않는다.** `&` 로 띄우고 `wait` 해야 진짜로 겹친다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 무슨 일이 일어났는가 — 기제
|
||||
|
||||
오류 메시지가 **두 종류**인 것이 단서였다.
|
||||
|
||||
| 메시지 | 뜻 |
|
||||
|---|---|
|
||||
| `Maximum allowed refresh token reuse exceeded` | **재사용 탐지가 발동** |
|
||||
| `Session doesn't have required client` | **그 여파** — client session 이 이미 없다 |
|
||||
|
||||
DB 로 확인했다.
|
||||
|
||||
```sql
|
||||
select us.user_session_id,
|
||||
(select count(*) from offline_client_session cs
|
||||
where cs.user_session_id = us.user_session_id) as client_sessions
|
||||
from offline_user_session us where us.user_session_id = '<sid>';
|
||||
```
|
||||
|
||||
```
|
||||
경쟁을 겪은 세션: BvFiB01Rntz1FcLdf7zG4BNt client_sessions = 0 ← 제거됨
|
||||
정상 세션(대조군): JT-XuepgutWcE273QwAnIXta client_sessions = 1
|
||||
```
|
||||
|
||||
**user session 은 남고 client session 만 제거된다.**
|
||||
|
||||
```
|
||||
user session "이 브라우저는 labuser 로 로그인함" ← 남는다
|
||||
└─ client session "그중 bff-confidential 에 대한 상태" ← 지워진다
|
||||
```
|
||||
|
||||
그래서 오류가 `"Session doesn't have required client"` 다 —
|
||||
**세션은 있는데 그 클라이언트 몫이 없다.**
|
||||
|
||||
### 그래서 이긴 요청도 죽는다
|
||||
|
||||
```
|
||||
t0 5개가 동시에 도착
|
||||
t1 하나가 처리를 시작 → 새 토큰 발급 준비
|
||||
t2 다른 것들이 같은 옛 토큰으로 들어옴 → 재사용 탐지 발동
|
||||
t3 ★ client session 제거
|
||||
t4 t1 의 응답이 나간다 → HTTP 200, 새 토큰
|
||||
t5 그 토큰을 쓰면 → client session 이 없다 → 400
|
||||
```
|
||||
|
||||
**애플리케이션은 200 을 받았으므로 성공했다고 믿는다.**
|
||||
다음 요청에서야 끊긴 것을 안다. **오류가 지연되어 나타난다.**
|
||||
|
||||
---
|
||||
|
||||
## 4. 정책을 바꿔 비교했다
|
||||
|
||||
### 구성 B — rotation OFF
|
||||
|
||||
```
|
||||
1: 200 2: 200 3: 200 4: 200 5: 200
|
||||
성공 5 / 5
|
||||
이긴 토큰 재사용: HTTP 200
|
||||
남은 client_session: 1
|
||||
```
|
||||
|
||||
**전부 성공하고 세션도 멀쩡하다.** 같은 refresh token 을 계속 쓸 수 있으므로
|
||||
경쟁 자체가 성립하지 않는다.
|
||||
|
||||
**대신 잃는 것** — 토큰이 유출되면 **만료까지 계속 쓸 수 있다.**
|
||||
rotation 의 목적이 그 창을 좁히는 것이었다.
|
||||
|
||||
### 구성 C — rotation ON · maxReuse=1
|
||||
|
||||
```
|
||||
1: 200
|
||||
2: 400 "Session doesn't have required client"
|
||||
3: 200
|
||||
4: 400 "Maximum allowed refresh token reuse exceeded"
|
||||
5: 400 "Session doesn't have required client"
|
||||
성공 2 / 5
|
||||
이긴 토큰 재사용: HTTP 400
|
||||
남은 client_session: 0
|
||||
```
|
||||
|
||||
**허용치를 1로 올려도 세션은 파괴됐다.**
|
||||
|
||||
> **`refreshTokenMaxReuse` 를 올리는 것은 해법이 아니다.**
|
||||
> 동시 요청이 N 개면 `maxReuse ≥ N-1` 이어야 하는데, 그러면
|
||||
> **rotation 의 보안 목적이 사라진다.** 값을 올려 버티려는 시도는
|
||||
> "몇 개까지 동시에 올 것인가"를 맞춰야 하는 문제로 바뀔 뿐이다.
|
||||
|
||||
---
|
||||
|
||||
## 5. Q2 검증 항목 대조
|
||||
|
||||
| # | Q2 의 검증 | 결과 |
|
||||
|---|---|---|
|
||||
| 1 | 동시 갱신 시 각 replica 동작 | **1개만 200, 나머지 400. 그런데 200 도 무효** |
|
||||
| 2 | 사용자 화면에 로그인 만료로 보이나 일시 오류로 보이나 | **로그인 만료로 보인다** — 세션이 실제로 없어졌으므로 |
|
||||
| 3 | 새 token 을 다시 읽어 **재시도하면 성공하는가** | **★ 실패한다.** client session 이 없어 어떤 토큰도 안 통한다 |
|
||||
| 4 | 한 곳에서만 갱신할지 / 각자 하고 재시도할지 | **재시도로는 회복 불가 → 한 곳에서만** |
|
||||
| 5 | lock 을 어디에 두고 얼마나 / 잡은 채 죽으면 | **아래 6절** |
|
||||
| 6 | 갱신 실패를 로그인 만료와 구분할 수 있는가 | **구분할 필요가 없다 — 실제로 로그인 만료다** |
|
||||
| 7 | rotation 전제를 바꿔서 비교 | **구성 B/C 로 측정 완료** |
|
||||
|
||||
**3번이 이 실험의 핵심이다.** Q2 는 "재시도하면 성공하는가"를 열어뒀는데,
|
||||
**답은 아니오**이고 그래서 판정 기준이 자동으로 lock 쪽으로 결정된다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 그래서 무엇을 해야 하는가
|
||||
|
||||
### lock 이 필요하다 — 그런데 어디에
|
||||
|
||||
```
|
||||
BFF replica 1 ─┐
|
||||
├─▶ 같은 (client, principal) 항목
|
||||
BFF replica 2 ─┘
|
||||
```
|
||||
|
||||
**lock 은 저장소 쪽에 있어야 한다.** 프로세스 안의 `synchronized` 는
|
||||
replica 를 넘지 못한다.
|
||||
|
||||
| 후보 | |
|
||||
|---|---|
|
||||
| **PostgreSQL 행 잠금** | `SELECT ... FOR UPDATE` — **A-0 에서 Keycloak 자신이 쓰는 방식** |
|
||||
| Redis 분산 lock | `SET NX PX` — TTL 로 스스로 풀린다 |
|
||||
| 갱신 전용 인스턴스 | 단일 지점. 그 인스턴스가 죽으면? |
|
||||
|
||||
**첫 번째가 자연스럽다** — 토큰이 이미 PostgreSQL 에 있고(B-2),
|
||||
Keycloak 도 세션 갱신에 같은 기법을 쓴다.
|
||||
|
||||
```sql
|
||||
-- A-0 에서 Keycloak 이 실제로 쓰는 것
|
||||
select VERSION from OFFLINE_USER_SESSION ... for no key update skip locked
|
||||
```
|
||||
|
||||
### lock 을 잡은 채 죽으면 (Q2 미지수 5번)
|
||||
|
||||
| 방식 | 프로세스가 죽으면 |
|
||||
|---|---|
|
||||
| **DB 행 잠금** | **연결이 끊기면 자동 해제** — 가장 안전하다 |
|
||||
| Redis lock + TTL | TTL 만료까지 막힌다. TTL 이 짧으면 **중복 갱신**, 길면 **정지** |
|
||||
|
||||
**DB 잠금이 이 문제에서 유리한 이유가 여기 있다** — 잠금의 수명이
|
||||
**연결의 수명**과 묶여 있어 따로 관리할 것이 없다.
|
||||
|
||||
**B-5(Redis 상실)에서 Redis lock 의 이 약점을 재볼 수 있다.**
|
||||
|
||||
---
|
||||
|
||||
## 7. 재현 절차 (명령어)
|
||||
|
||||
```bash
|
||||
# 1. 전제 맞추기
|
||||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||||
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=0
|
||||
|
||||
# 2. refresh token 하나 확보 (direct grant)
|
||||
curl -s -X POST $KC -d grant_type=password -d client_id=bff-confidential \
|
||||
-d client_secret=bff-lab-secret -d username=labuser -d password=labpass -d scope=openid
|
||||
|
||||
# 3. 동시에 5개 — & 와 wait 이 없으면 재현되지 않는다
|
||||
i=1; while [ $i -le 5 ]; do ( curl ... -d refresh_token=$RT > /tmp/c$i ) & i=$((i+1)); done; wait
|
||||
|
||||
# 4. ★ 이긴 요청의 토큰을 다시 써본다 — 여기서 진짜 답이 나온다
|
||||
curl -s -o /dev/null -w '%{http_code}' -X POST $KC -d grant_type=refresh_token -d refresh_token=$NEW
|
||||
|
||||
# 5. 기제 확인 — client session 이 지워졌는지
|
||||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||||
"select us.user_session_id,
|
||||
(select count(*) from offline_client_session cs
|
||||
where cs.user_session_id = us.user_session_id) as client_sessions
|
||||
from offline_user_session us where us.user_session_id = '<sid>'"
|
||||
|
||||
# 6. 정책 비교 — revokeRefreshToken 과 refreshTokenMaxReuse 를 바꿔가며 3~5 반복
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 다음 실험에 남기는 것
|
||||
|
||||
| 실험 | 이 실험이 준 것 |
|
||||
|---|---|
|
||||
| **B-5** Redis 상실 | Redis lock 을 쓴다면 **Redis 가 죽었을 때 갱신이 멈춘다** |
|
||||
| **B-6** 암호화 key 교체 | 같은 "동시 접근" 문제의 다른 얼굴 |
|
||||
| **A-6** 지연 주입 (기록 정정) | A-6 에서 낙관적 락 충돌이 0 이었던 이유가 확인된다 — **로그인은 새 행을 만들 뿐**이고, 다투는 것은 **여기서처럼 같은 항목을 갱신할 때**다 |
|
||||
| 설계 | **재시도로 회복되지 않는다 → lock.** Q2 의 판정 기준이 결정됐다 |
|
||||
Reference in New Issue
Block a user