Files
keycloak-pattern/docs/guides/experiments/b3-refresh-token-contention.md
T
DongHyeonkaandClaude Opus 5 6f6ab86345 docs(guides): reproduction guides for all 26 experiments
Written by subagents running under the writing-practitioner-guides skill,
one guide per experiment, 22,566 lines. Each walks a reader from baseline
capture through injection, injection verification, observation and recovery.

Section 3 carries the weight in most of them. Injection failed silently nine
times in this lab, and a failed injection looks exactly like no effect — so
the guides verify the target is actually in the intended state before
reading any result. A-4 makes virsh list the only proof because the node
reads Ready for 40 seconds after the machine is off; A-5 makes the packet
counter the sole go/no-go because a rule on the wrong node produces an empty
result that reads like a finding; A-6 quotes the run where 적용완료 was
printed between four Cannot find device "eth0" lines.

The traps the guides are built around are ones that invert a conclusion
rather than merely annoy:

  A-0   emptying the session table without a restart leaves cache entries
        that get counted as replication arriving
  A-2   dropping -o /dev/null fuses body and status into one string
  A-3   presence of "ready to accept connections" instead of its timestamp
  B-2   row count alone reads an UPDATE as nothing having happened
  B-4   tr ',' '\n' splits ["admin","editor"] so only admin is seen
  B-7   no login screen means the cookie died and SSO re-authenticated
  C-1   counting sessions without joining realm counts your own kcadm one
  D-1   kubectl exec without -i restores nothing and still exits 0
  D-4a  "ran with error output" is what success looks like

Every quoted block is copied from docs/evidence/ and marked 실측; reshaped
commands are marked 미검증 rather than passed off as measured. Where a source
document carries a ★ correction the guides follow the corrected claim — A-7's
REVOKED_TOKEN hypothesis, C-1's session count, B-2's schema attribution.

Two hazards are stated rather than smoothed over: B-6 deletes a key that
cannot be recreated, and D-1/D-4 need host sudo, which asks for a password,
so those steps say a person must type them.

Audit over all 26: 672 interpretation pairs, 486 evidence citations, 117
undo sections, and zero occurrences of the patterns the skill forbids —
no python data processing, no deprecated kubectl get endpoints, no
placeholders, no bare kcadm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:29:00 +09:00

836 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# B-3 재현 가이드 — 같은 refresh token 을 다섯 번 동시에 던지고 세션이 사라지는 것을 본다
해설 문서: [`docs/experiment-b3-refresh-token-contention.md`](../../experiment-b3-refresh-token-contention.md) ·
증거 원문: [`docs/evidence/b3-refresh-contention/`](../../evidence/b3-refresh-contention/)
## 이 가이드가 끝나면
당신 터미널에서 이것들을 **직접 본다.**
| 보게 되는 것 | 어디서 |
|---|---|
| 다섯 중 하나만 `200` 이고 나머지는 `400` | 파드 안 `curl` · `&``wait` |
| 오류 메시지가 **두 종류**인 것 | `Maximum allowed refresh token reuse exceeded` / `Session doesn't have required client` |
| **이긴 요청이 받은 토큰조차 못 쓰는 것** | 그 토큰으로 한 번 더 갱신 → `400` |
| user session 은 남고 **client session 만 사라진** 것 | PostgreSQL, 정상 세션과 나란히 |
| `refreshTokenMaxReuse` 를 올려도 안 되는 것 | 구성 A/B/C 비교 |
## 전제
- [`05-keycloak`](../05-keycloak/) · [`06-observability`](../06-observability/) 가 끝나 있다.
- [`B-2`](b2-multi-instance-session.md) 가 끝나 있다. **토큰이 공유되어야
경쟁이 성립한다** — 다만 이 실험은 Keycloak 쪽 동작만 분리해 보려고
**BFF 를 거치지 않고** 토큰 엔드포인트를 직접 친다.
- 명령은 **`kc-lab-1` 에서** 친다. `kubectl``sudo` 로 쓴다.
- **브라우저는 필요 없다.** direct grant(`grant_type=password`)로 토큰을
만들므로 전 구간을 터미널에서 한다.
- realm 은 `keycloak-patterns`, 사용자는 `labuser` / `labpass`,
클라이언트는 `bff-confidential`.
## 주의 — 이건 realm 설정을 바꾸는 실험이다
`revokeRefreshToken` 을 켠다. **realm 전체에 걸린다** — 그 realm 을 쓰는 다른
실험(B-2 의 BFF 로그인 포함)이 이 설정의 영향을 받는다. **실험대에서만 한다.**
전 구간 약 20분이고, 되돌리는 명령은 [2-2](#2-2-적용) 와 [5-1](#5-1-realm-설정을-되돌린다)
에 있다. 중간에 그만두려면 5-1 의 한 줄이면 된다.
## 표시 규약
| 표시 | 뜻 |
|---|---|
| **실측** | 2026-09-04 14:1614:17 KST 실행 기록의 **출력 원문**. 증거 파일에 그대로 있다 |
| **형태** | 값이 매번 달라지는 출력. 모양만 보이고 sid·길이는 당신 것과 다르다 |
| **미검증** | 손으로 치기 좋게 이 가이드에서 고친 형태. 원래 실행은 스크립트였다 |
sid·토큰 길이는 **당신 환경에서 다르다.** 이 문서는 자리표시자(`<...>`)를
쓰지 않는 대신, 그 값을 뽑는 명령을 먼저 적는다. 예시로 실린 값은 전부 위
실행 기록의 실제 값이다.
---
# 0. 왜 이 실험을 하는가
B-2 가 토큰을 PostgreSQL 로 옮겼다. 그래서 **두 replica 가 같은 행을 본다.**
그리고 조회 키에 session id 가 없으니 **같은 사용자의 두 브라우저도 같은 행을
본다.** 그 행에는 refresh token 이 하나 들어 있다.
**둘이 동시에 그 하나를 갱신하면 무슨 일이 일어나는가.**
| | 예측 |
|---|---|
| 통념 | **하나는 성공하고 하나는 실패한다.** 실패한 쪽이 새 토큰을 다시 읽어 재시도하면 된다 |
| B-3 이 재는 것 | 진짜 그런가. **그리고 이긴 쪽은 멀쩡한가** |
이 구별이 설계를 가른다.
```
실패가 사용자에게 안 보인다 → 재시도로 덮으면 된다
실패가 사용자에게 보인다 → 애초에 겹치지 않게 lock 을 걸어야 한다
```
**재시도로 회복되면 lock 이 필요 없고, 회복 안 되면 lock 말고 답이 없다.**
그러니 재야 할 것은 「몇 개가 성공했나」가 아니라 **「이긴 요청의 토큰을 다시
쓸 수 있나」**다. 4-4 가 그 자리다.
> **개념 — 재사용 탐지(reuse detection)란 무엇인가.**
>
> 회전이 켜져 있으면 새 refresh token 을 줄 때 옛 것을 무효화한다. 그런데
> 무효화된 옛 토큰이 **다시 들어오면** 두 가지 중 하나다.
>
> ```
> ① 정상 클라이언트가 응답을 못 받아 재시도했다 (무해)
> ② 토큰이 유출되어 공격자가 쓰고 있다 (치명)
> ```
>
> **서버는 둘을 구별할 수 없다.** 그래서 OAuth 2.0 보안 권고는 **안전한
> 쪽으로 가정하고 세션 전체를 무효화**하라고 말한다. 이 실험이 보는
> 파괴는 **버그가 아니라 그 규격이 시키는 대로 동작한 결과**다.
> 그래서 "고쳐 달라"가 아니라 "겹치지 않게 하라"가 답이 된다.
---
# 1. 기준선 — 회전을 켜기 전에
**시험군만 재는 측정은 측정이 아니다.** 회전이 꺼진 상태에서 같은 명령을
먼저 돌려 두어야, 나중에 나오는 400 이 「원래 그런 것」인지 「내가 켠 것」
때문인지 구별된다.
```
파드 → realm 설정 → 탐침 파드 → 토큰 하나 → 대조군(순차) → 대조군(정상 세션)
```
## 1-1. 파드가 정상인가
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -o wide
```
**형태**
```
NAME READY STATUS RESTARTS AGE IP NODE
keycloak-0 1/1 Running 0 2d 10.42.1.43 kc-lab-2
keycloak-1 1/1 Running 0 2d 10.42.0.35 kc-lab-1
postgres-... 1/1 Running 0 5d ... kc-lab-2
```
**어디를 봐야 하는가** — Keycloak 이 **둘 다** `1/1`. 하나가 NotReady 면
Service 가 전부 한쪽으로 보내고, 그러면 **동시성이 한 노드 안에서만** 생긴다.
이 실험은 그래도 재현되지만 「replica 를 넘는 경쟁」이라고 말할 수 없게 된다.
## 1-2. realm 이 지금 무엇으로 설정되어 있나
kcadm 은 먼저 로그인해야 쓸 수 있다. **한 번 하면 파드 안에 세션이 남는다.**
**하기**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
config credentials --server http://localhost:8080 --realm master --user admin \
--password "$(sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
```
> **비밀번호를 화면에 찍지 않는다.** 명령 치환으로 넘기므로 값은 터미널에도
> 셸 히스토리에도 남지 않는다. 존재와 길이만 보고 싶으면:
> ```bash
> sudo kubectl -n keycloak-lab get secret keycloak-lab-secrets \
> -o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
> ```
**확인**
```bash
sudo 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 }
```
**어디를 봐야 하는가** — 세 값 전부.
| 값 | 뜻 | 지금 |
|---|---|---|
| `revokeRefreshToken` | **회전 스위치** | `false`**꺼져 있다** |
| `refreshTokenMaxReuse` | 회전이 켜졌을 때 몇 번까지 봐줄 것인가 | `0` |
| `accessTokenLifespan` | access token 수명(초) | `60` |
**이 결과가 의미하는 것****기본값은 회전이 꺼져 있다.** Q2 는
*"realm 이 refresh token rotation 과 재사용 허용 0회를 쓰게 되어서"*
전제로 하므로, **그 전제를 만드는 것이 이 실험의 주입**이다. 지금 그대로
재면 Q2 와 다른 것을 재게 된다.
`accessTokenLifespan=60` 은 B-0 에서 **이 실험을 위해** 넣어 둔 값이다.
만료를 기다리는 시간이 짧아야 재현이 된다.
## 1-3. 상주 탐침 파드를 띄운다
Keycloak 이미지에는 `curl``wget` 도 없다(`exit 127`). 그리고 이 실험은
**토큰을 단계 사이로 넘겨야** 한다 — 발급받은 토큰을 뒤 단계에서 써야 하므로
`--rm` 임시 파드로는 안 된다. **파드를 하나 띄워 두고 `exec` 로 이어간다.**
**하기**
```bash
sudo kubectl -n keycloak-lab run b3-probe --image=curlimages/curl:8.11.1 \
--restart=Never \
--env="KC=http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns/protocol/openid-connect/token" \
--env="CS=$(sudo kubectl -n keycloak-lab get secret bff-secrets \
-o jsonpath='{.data.KEYCLOAK_CLIENT_SECRET}' | base64 -d)" \
--command -- sleep 7200
sudo kubectl -n keycloak-lab wait --for=condition=Ready pod/b3-probe --timeout=120s
```
**형태**
```
pod/b3-probe condition met
```
**되돌리기**
```bash
sudo kubectl -n keycloak-lab delete pod b3-probe --ignore-not-found
```
**확인** — 환경변수가 들어갔나. **값이 아니라 길이만 본다**
```bash
sudo kubectl -n keycloak-lab exec b3-probe -- sh -c 'echo "KC=$KC CS길이=${#CS}"'
```
**형태**
```
KC=http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns/protocol/openid-connect/token CS길이=15
```
`CS길이=0` 이면 `--env` 가 빈 값을 넘긴 것이다. 파드를 지우고 다시 띄운다.
> **왜 Service 로 가는가.** A-1·A-2 는 「어느 노드가 답했나」가 질문이라 파드
> IP 로 직접 쳤다. 여기는 반대다 — **replica 를 넘는 경쟁**이 질문이므로
> Service 가 요청을 흩는 것이 오히려 필요한 조건이다.
**이제부터는 이 파드 안에서 친다.** 셸에 들어가는 편이 편하다.
```bash
sudo kubectl -n keycloak-lab exec -it b3-probe -- sh
```
프롬프트가 `/ $` 로 바뀐다. 나올 때는 `exit`**파드는 안 지워진다**
(`--rm` 이 없다).
## 1-4. 토큰 하나를 발급받고 sid 를 뽑는다
**하기 ①** — 파드 안에서. **처음 한 번은 응답을 통째로 본다**
```sh
curl -s -X POST "$KC" \
-d grant_type=password -d client_id=bff-confidential \
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid
```
**형태** — 한 줄 JSON 이 나온다
```json
{"access_token":"eyJhbGciOi...","expires_in":60,"refresh_expires_in":1800,
"refresh_token":"eyJhbGciOi...","token_type":"Bearer","scope":"openid profile email"}
```
**어디를 봐야 하는가**`expires_in` 이 60. 1-2 에서 본 `accessTokenLifespan`
그대로다. 여기가 `{"error":"unauthorized_client"}` 면 클라이언트에 direct
grant 가 꺼진 것이고, `{"error":"invalid_grant"}` 면 사용자 이름/비밀번호다.
**하기 ②** — 변수에 담고 sid 를 뽑는다
```sh
R=$(curl -s -X POST "$KC" \
-d grant_type=password -d client_id=bff-confidential \
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid)
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
SID=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' | cut -d. -f2 \
| sed 's/$/==/' | base64 -d 2>/dev/null | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p')
echo "refresh=${#RT}자 SID=$SID"
```
**실측** — [`01-concurrent-refresh.txt`](../../evidence/b3-refresh-contention/01-concurrent-refresh.txt)
```
=== [1] refresh token 하나 확보 ===
토큰 길이: 811
jti: 8e7e3ee2-0dc8-573d-58ec-d12651a50b9c
sid: BvFiB01Rntz1FcLdf7zG4BNt
```
**어디를 봐야 하는가****`SID` 를 종이에 적어 둔다.** 4-5 에서 DB 를 뒤질 때
이 값이 필요하고, 그때는 **파드 밖**이라 변수가 안 넘어간다.
> `sid` 가 빈 줄이면 base64 패딩 때문이다. `sed 's/$/==/'` 가 그 보정이고,
> 그래도 안 나오면 **base64url 문자(`-` `_`)** 때문일 수 있다. 그때는:
> ```sh
> echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' \
> | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
> ```
> **미검증** — 원래 실행은 위쪽 형태를 썼다. 페이로드 전체가 나오면 그 안에서
> `"sid"` 를 눈으로 찾는다.
## 1-5. 대조군 — 지금은 무엇이 되는가
### ① 순차로 다섯 번 갱신한다
**하기** — 파드 안에서. `&` 없이, 한 번에 하나씩
```sh
for i in 1 2 3 4 5; do
R=$(curl -s -w '\n%{http_code}' -X POST "$KC" \
-d grant_type=refresh_token -d client_id=bff-confidential \
-d "client_secret=$CS" -d "refresh_token=$RT")
echo "순차 $i: $(echo "$R" | tail -1)"
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
done
```
**미검증** — 증거 파일에는 순차 실행 기록이 없다. 해설 문서는
**"순차 실행이면 재현되지 않는다"** 고 말하며, 이 절은 그것을 당신 손으로
확인하는 자리다.
**어디를 봐야 하는가** — 다섯 줄 전부 `200` 이어야 한다.
> **회전이 켜지면 `RT` 를 매번 다시 담아야 한다.** 위 루프가 그렇게 되어
> 있다. 옛 것을 계속 쓰면 뒤에 나오는 400 이 「경쟁」 때문인지 「내가 옛
> 토큰을 썼기」 때문인지 구별이 안 된다. **이 실험에서 가장 흔한 자기오염이다.**
### ② 경쟁을 겪지 않은 세션은 어떻게 생겼나
4-5 에서 볼 DB 모양을 **지금 미리 본다.** 이게 없으면 나중에 나오는 `0`
「경쟁 때문」인지 「원래 그런 표」인지 모른다.
**확인****파드 밖**(kc-lab-1)에서. 아래의 sid 자리에는 **1-4 에서 적어 둔
당신의 `SID`** 를 넣는다. 여기 실린 값은 원래 실행의 대조군 세션 것이다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select us.user_session_id, us.offline_flag,
(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 = 'JT-XuepgutWcE273QwAnIXta'"
```
**실측** — [`03-client-session-removed.txt`](../../evidence/b3-refresh-contention/03-client-session-removed.txt)
의 대조군 부분
```
=== 대조: 정상 세션 하나를 새로 만들어 비교 ===
새 sid: JT-XuepgutWcE273QwAnIXta
user_session_id | client_sessions
--------------------------+-----------------
JT-XuepgutWcE273QwAnIXta | 1
(1 row)
```
**어디를 봐야 하는가****`client_sessions = 1`.** 정상 세션은 이렇게 생겼다.
> **개념 — user session 과 client session 은 다른 것이다.**
>
> ```
> user session "이 브라우저는 labuser 로 로그인함"
> ├─ client session : bff-confidential
> └─ client session : oauth2-proxy
> ```
>
> 사용자가 한 번 로그인하고 여러 애플리케이션에 들어가면 **user session 하나
> 아래에 client session 이 여럿** 달린다. 그게 SSO 다.
> **재사용 탐지는 이 중 client session 만 제거한다** — 4-5 에서 그것을 본다.
>
> 온라인 세션인데 표 이름이 `offline_user_session` 인 것이 헷갈리는데,
> `offline_flag` 열이 그것을 가른다. 위 출력의 `offline_flag = 0` 이
> 「온라인 세션」이다.
---
# 2. 주입 — 회전을 켠다
여기부터 상태가 바뀐다. **되돌리는 명령을 먼저 읽어 둔다.**
**되돌리기**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update realms/keycloak-patterns -s revokeRefreshToken=false -s refreshTokenMaxReuse=0
```
## 2-1. 무엇을 켜는가 — 이름이 헷갈린다
> **개념 — `revokeRefreshToken` 이 회전 스위치다.**
>
> 이름이 「회전(rotation)」이 아니라 **「취소(revoke)」**다. 켜면 새 토큰을
> 줄 때 **옛 토큰을 무효화**한다. 그 결과가 회전이다.
>
> | 설정 | 뜻 |
> |---|---|
> | `revokeRefreshToken` | **회전 스위치.** 켜면 새 토큰 발급 시 옛 토큰을 무효화 |
> | `refreshTokenMaxReuse` | 그 위에서 **몇 번까지 봐줄 것인가** |
>
> **`refreshTokenMaxReuse` 는 `revokeRefreshToken` 이 켜져야 의미가 있다.**
> 꺼진 상태에서 이 값만 올리면 아무 일도 안 일어난다 — 무효화 자체가 없으니
> 「봐줄 횟수」를 셀 대상이 없다. 관리 콘솔에서 이 항목이 회색인 이유다.
## 2-2. 적용
**하기**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=0
date '+%H:%M:%S 회전 켬'
```
**형태** — 성공하면 아무 말도 안 한다(무소식이 좋은 소식이다)
```
14:16:12 회전 켬
```
**시각을 적어 둔다.** 4절의 결과를 이 시각 이후에 만든 토큰으로 재야 한다.
**켜기 전에 발급한 토큰으로 재면 안 된다** — 발급 시점의 정책이 아니라 검증
시점의 정책이 적용되므로 섞여서 해석이 안 된다.
---
# 3. 주입이 실제로 걸렸는지 확인한다
**결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 먼저 본다.**
## 3-1. 설정이 실제로 바뀌었나
**확인** — 1-2 와 **똑같은 명령**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get realms/keycloak-patterns \
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
```
**형태**
```json
{ "revokeRefreshToken" : true, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
```
**어디를 봐야 하는가**`revokeRefreshToken``true`. `false` 그대로면
`update` 가 다른 realm 에 갔거나 kcadm 세션이 만료된 것이다. kcadm 은
**실패해도 조용할 때가 있다** — 반드시 다시 읽어서 확인한다.
## 3-2. 엉뚱한 것을 죽이지 않았나
**확인**
```bash
sudo kubectl -n keycloak-lab get pods -l app=keycloak
```
**어디를 봐야 하는가****`RESTARTS` 가 여전히 0.** realm 설정 변경은 재시작을
일으키지 않는다. 여기서 재시작이 올랐다면 다른 것을 건드린 것이고, 그 상태로
재면 「경쟁」이 아니라 「재시작」을 재게 된다.
## 3-3. 회전이 실제로 동작하는가 — 한 번만 갱신해 본다
**동시성을 넣기 전에, 회전 자체가 도는지 확인한다.**
**하기** — 파드 안에서. 새 토큰을 하나 받고, **한 번 갱신한 뒤 옛 것을 다시 쓴다**
```sh
R=$(curl -s -X POST "$KC" \
-d grant_type=password -d client_id=bff-confidential \
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid)
OLD=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
curl -s -o /dev/null -w '1회차(옛 토큰): %{http_code}\n' -X POST "$KC" \
-d grant_type=refresh_token -d client_id=bff-confidential \
-d "client_secret=$CS" -d "refresh_token=$OLD"
curl -s -o /dev/null -w '2회차(같은 옛 토큰 재사용): %{http_code}\n' -X POST "$KC" \
-d grant_type=refresh_token -d client_id=bff-confidential \
-d "client_secret=$CS" -d "refresh_token=$OLD"
```
**미검증** — 이 절은 이 가이드가 덧붙인 사전 확인이다. 증거 파일에는 없다.
**어디를 봐야 하는가****1회차 `200`, 2회차 `400`.**
**이 결과가 의미하는 것** — 옛 토큰이 무효화된다 = 회전이 켜졌다.
2회차도 `200` 이면 **회전이 안 켜진 것**이고, 그 상태로 4절을 돌리면 다섯 개가
전부 200 으로 나온다 — 그건 「경쟁이 없었다」가 아니라 「주입이 안 걸렸다」다.
---
# 4. 효과를 관찰한다
## 4-1. 깨끗한 토큰을 하나 새로 받는다
3-3 에서 쓴 토큰은 이미 무효다. **새로 시작한다.**
**하기** — 파드 안에서
```sh
R=$(curl -s -X POST "$KC" \
-d grant_type=password -d client_id=bff-confidential \
-d "client_secret=$CS" -d username=labuser -d password=labpass -d scope=openid)
RT=$(echo "$R" | sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p')
SID=$(echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' | cut -d. -f2 \
| sed 's/$/==/' | base64 -d 2>/dev/null | sed -n 's/.*"sid":"\([^"]*\)".*/\1/p')
echo "refresh=${#RT}자 SID=$SID"
```
**`SID` 를 다시 적어 둔다.** 4-5 에서 쓴다.
## 4-2. ★ 동시에 다섯 개 — `&` 와 `wait` 이 없으면 재현되지 않는다
**이 절이 이 실험의 전부다.** 순차로 돌리면 아무 일도 안 일어난다(1-5 에서
직접 봤다). 진짜로 겹쳐야 한다.
**하기** — 파드 안에서
```sh
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=$CS" -d "refresh_token=$RT" > /tmp/c$i ) &
i=$((i+1))
done
wait
for i in 1 2 3 4 5; do
echo "요청 $i: HTTP $(cat /tmp/c$i) $(head -c 100 /tmp/b$i)"
done
```
**미검증** — 원래 실행은 스크립트였다. 이 형태는 손으로 치기 좋게 고친 것이고,
**본문과 응답 코드를 파일로 갈라 놓아 순서대로 다시 읽을 수 있게** 했다.
다섯 개를 동시에 띄우면 출력이 뒤섞여 어느 줄이 어느 요청인지 알 수 없다 —
그래서 파일로 받고 `wait` 뒤에 순서대로 읽는다.
**어디를 봐야 하는가** — 셸 문법 세 조각이 전부다.
```
( ... ) & 서브셸을 백그라운드로 띄운다 → 다섯 개가 동시에 난다
wait 띄운 것이 전부 끝날 때까지 기다린다
> /tmp/c$i 각자 자기 파일에 쓴다 → 출력이 안 섞인다
```
**`&` 를 빼면 while 루프가 하나씩 기다리고, 그러면 이 실험은 재현되지 않는다.**
`wait` 을 빼면 결과 파일을 읽을 때 아직 안 끝난 것이 있어 빈 줄이 나온다.
## 4-3. 결과 — 오류가 두 종류다
**실측** — [`01-concurrent-refresh.txt`](../../evidence/b3-refresh-contention/01-concurrent-refresh.txt)
```
=== [2] 같은 refresh token 으로 동시에 5회 갱신 ===
요청 1: HTTP 400 {"error":"invalid_grant","error_description":"Maximum allowed refresh token reuse exceeded"}
요청 2: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
요청 3: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
요청 4: HTTP 400 {"error":"invalid_grant","error_description":"Session doesn't have required client"}
요청 5: HTTP 200 {"access_token":"...(발급됨)
```
**어디를 봐야 하는가****성공 개수가 아니라 오류 메시지가 두 종류인 것.**
| 메시지 | 뜻 |
|---|---|
| `Maximum allowed refresh token reuse exceeded` | **재사용 탐지가 발동** |
| `Session doesn't have required client` | **그 여파** — client session 이 이미 없다 |
**이 결과가 의미하는 것** — 만약 「하나만 이기고 나머지는 진다」였다면 지는
쪽 메시지는 **전부 같아야** 한다. 두 종류라는 것은 **중간에 상태가 바뀌었다**
는 뜻이다. 그 바뀐 상태가 무엇인지가 4-5 다.
> 성공한 번호는 당신 환경에서 다르다. 증거에서는 5번이었지만 순서는
> 스케줄링에 달렸다. **몇 번이 이겼는가는 아무 의미가 없다.**
## 4-4. ★ 이긴 요청의 토큰을 다시 써 본다 — 여기서 진짜 답이 나온다
**하기** — 파드 안에서. 다섯 응답 중 `refresh_token` 이 들어 있는 것을 꺼낸다
```sh
NEW=$(cat /tmp/b1 /tmp/b2 /tmp/b3 /tmp/b4 /tmp/b5 \
| sed -n 's/.*"refresh_token":"\([^"]*\)".*/\1/p' | head -1)
echo "새 refresh token 길이: ${#NEW}"
curl -s -w '\n%{http_code}\n' -X POST "$KC" \
-d grant_type=refresh_token -d client_id=bff-confidential \
-d "client_secret=$CS" -d "refresh_token=$NEW"
```
**실측** — [`02-session-impact.txt`](../../evidence/b3-refresh-contention/02-session-impact.txt)
```
=== [3] 이긴 요청이 받은 새 토큰은 쓸 수 있는가 ===
새 refresh token 길이: 810
그 토큰으로 다시 갱신: HTTP 400
{"error":"invalid_grant","error_description":"Session doesn't have required client"}
```
**어디를 봐야 하는가****`400`.** 그리고 메시지가
`Session doesn't have required client`.
**이 결과가 의미하는 것****이긴 요청조차 쓸 수 없는 토큰을 받았다.**
```
애플리케이션이 본 것 : HTTP 200 + 새 토큰 → "성공했다"
실제 상태 : 세션이 이미 없다 → 다음 요청에서 끊긴다
```
**오류가 지연되어 나타난다.** 200 을 받은 코드는 성공했다고 믿고 토큰을
저장한다. 끊긴 것은 **그다음 요청에서** 안다. 로그를 볼 때 원인 시각과 증상
시각이 어긋나 보이는 이유가 이것이다.
> **여기서 「재시도하면 되지 않나」가 무너진다.** 새 토큰을 다시 읽어
> 재시도해도 **그 토큰이 이미 무효**다. 재시도할 대상이 없다.
## 4-5. 기제 확정 — 무엇이 사라졌는가
**확인** — **파드 밖**에서. `SID` 는 4-1 에서 적어 둔 값이다
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select us.user_session_id, us.offline_flag, us.last_session_refresh
from offline_user_session us
where us.user_session_id = 'BvFiB01Rntz1FcLdf7zG4BNt'"
```
**실측** — [`02-session-impact.txt`](../../evidence/b3-refresh-contention/02-session-impact.txt)
```
=== [4] 그 sid 의 세션이 DB 에 남아 있는가 ===
user_session_id | offline_flag | last_session_refresh
--------------------------+--------------+----------------------
BvFiB01Rntz1FcLdf7zG4BNt | 0 | 1788498996
(1 row)
```
**어디를 봐야 하는가****행이 남아 있다.** 세션이 통째로 지워진 것이 아니다.
그러면 왜 `Session doesn't have required client` 인가. **client session 을 센다.**
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select us.user_session_id, us.offline_flag,
(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 = 'BvFiB01Rntz1FcLdf7zG4BNt'"
```
**실측** — [`03-client-session-removed.txt`](../../evidence/b3-refresh-contention/03-client-session-removed.txt)
```
=== user session 과 client session 을 나눠서 본다 ===
user_session_id | offline_flag | client_sessions
--------------------------+--------------+-----------------
BvFiB01Rntz1FcLdf7zG4BNt | 0 | 0
(1 row)
```
**어디를 봐야 하는가****`client_sessions = 0`.** 1-5 의 대조군은 `1` 이었다.
**같은 명령, 다른 결과 — 그것이 이 실험의 판정이다.**
**이 결과가 의미하는 것**
```
user session "이 브라우저는 labuser 로 로그인함" ← 남는다
└─ client session "그중 bff-confidential 에 대한 상태" ← 지워졌다
```
그래서 오류 문구가 정확히 그 말을 한다 — **세션은 있는데 그 클라이언트 몫이
없다.** 메시지를 오해해서 「세션이 만료됐다」로 읽으면 엉뚱한 곳을 고치게 된다.
## 4-6. 폐기 목록에 실린 것이 아니다
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
"select count(*) as revoked_count from revoked_token"
```
**실측** — [`02-session-impact.txt`](../../evidence/b3-refresh-contention/02-session-impact.txt)
```
=== [5] revoked_token 테이블 ===
revoked_count
---------------
0
(1 row)
```
**어디를 봐야 하는가****`0`.**
**이 결과가 의미하는 것** — 「토큰을 블랙리스트에 올려서 막는다」가 아니다.
**client session 이 사라져서 검증할 대상이 없어진 것**이다. 토큰을 지우는
방식이었다면 다른 토큰은 살아 있어야 하는데, 여기서는 **그 client 에 대한
모든 토큰이 한꺼번에 죽는다.** 4-4 의 결과가 그것이다.
## 4-7. 시간선 — 왜 이긴 쪽도 죽는가
```
t0 5개가 동시에 도착
t1 하나가 처리를 시작 → 새 토큰 발급 준비
t2 다른 것들이 같은 옛 토큰으로 들어옴 → 재사용 탐지 발동
t3 ★ client session 제거
t4 t1 의 응답이 나간다 → HTTP 200, 새 토큰
t5 그 토큰을 쓰면 → client session 이 없다 → 400
```
**t3 와 t4 의 순서가 전부다.** 응답을 만들던 요청은 이미 「성공」이 확정된
상태로 나가고, 그 사이 바닥이 빠진다.
## 4-8. 정책을 바꿔 비교한다
**한 번 더 재기 전에 세션을 새로 만든다.** 파괴된 세션으로 재면 전부 400 이다.
### 구성 B — 회전 OFF
**하기**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update realms/keycloak-patterns -s revokeRefreshToken=false
```
파드 안에서 **4-1 → 4-2 → 4-4 → 4-5 를 그대로 반복**한다.
**실측** — [`04-policy-comparison.txt`](../../evidence/b3-refresh-contention/04-policy-comparison.txt)
```
=== 구성 B: rotation OFF (revokeRefreshToken=false) ===
sid=iW1CGyO7COdyJLryIrCt3njk
1: 200
2: 200
3: 200
4: 200
5: 200
성공 5 / 5
이긴 토큰 재사용: HTTP 200
남은 client_session: 1
```
**어디를 봐야 하는가****전부 200 이고 세션도 멀쩡하다.**
**이 결과가 의미하는 것** — 같은 refresh token 을 계속 쓸 수 있으므로
**경쟁 자체가 성립하지 않는다.** 대신 잃는 것 — 토큰이 유출되면 **만료까지
계속 쓸 수 있다.** 회전의 목적이 그 창을 좁히는 것이었다.
### 구성 C — 회전 ON · 재사용 1회 허용
**하기**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=1
```
다시 반복한다.
**실측** — [`04-policy-comparison.txt`](../../evidence/b3-refresh-contention/04-policy-comparison.txt)
```
=== 구성 C: rotation ON + 재사용 1회 허용 (maxReuse=1) ===
sid=72c04JCdr0NpCHGQmXWW2wM8
1: 200
2: 400 "error_description":"Session doesn't have required client"
3: 200
4: 400 "error_description":"Maximum allowed refresh token reuse exceeded"
5: 400 "error_description":"Session doesn't have required client"
성공 2 / 5
이긴 토큰 재사용: HTTP 400
남은 client_session: 0
```
**어디를 봐야 하는가** — 성공이 1에서 2로 늘었지만 **`남은 client_session: 0`**
은 그대로다.
### 세 구성을 나란히
| 구성 | 성공 | 이긴 토큰 재사용 | client_session |
|---|---|---|---|
| **A** 회전 ON · maxReuse=0 | **1 / 5** | **400** | **0 — 파괴** |
| **B** 회전 OFF | **5 / 5** | 200 | **1 — 생존** |
| **C** 회전 ON · maxReuse=1 | **2 / 5** | **400** | **0 — 파괴** |
> **`refreshTokenMaxReuse` 를 올리는 것은 해법이 아니다.**
> 동시 요청이 N 개면 `maxReuse ≥ N-1` 이어야 하는데, 그러면 **회전의 보안
> 목적이 사라진다.** 값을 올려 버티려는 시도는 "몇 개까지 동시에 올
> 것인가"를 맞춰야 하는 문제로 바뀔 뿐이고, 그 답은 아무도 모른다.
**그래서 답은 lock 이다.** 그리고 lock 은 **저장소 쪽**에 있어야 한다 —
프로세스 안의 `synchronized` 는 replica 를 넘지 못한다.
| 후보 | |
|---|---|
| **PostgreSQL 행 잠금** | `SELECT ... FOR UPDATE`**A-0 에서 Keycloak 자신이 쓰는 방식** |
| Redis 분산 lock | `SET NX PX` — TTL 로 스스로 풀린다 |
| 갱신 전용 인스턴스 | 단일 지점. 그 인스턴스가 죽으면? |
**잠금의 수명이 연결의 수명과 묶이는 것**이 DB 잠금의 이점이다. 프로세스가
죽으면 연결이 끊기고 잠금은 자동으로 풀린다. Redis lock 은 TTL 이 짧으면
**중복 갱신**, 길면 **정지**다 — 그 약점은 [B-5](b5-redis-loss-persistence.md)
에서 다시 만난다.
---
# 5. 복구
## 5-1. realm 설정을 되돌린다
**하기**
```bash
sudo kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
update realms/keycloak-patterns -s revokeRefreshToken=false -s refreshTokenMaxReuse=0
date '+%H:%M:%S 회전 끔'
```
**확인** — 1-2 와 똑같은 명령으로 다시 읽는다
```bash
sudo 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 }
```
**1-2 의 실측과 세 값이 전부 같아야 한다.** `accessTokenLifespan` 이 60 이
아니면 다른 것도 건드린 것이다.
## 5-2. 탐침 파드를 지운다
**하기**
```bash
sudo kubectl -n keycloak-lab delete pod b3-probe --ignore-not-found
```
## 5-3. 실험이 만든 세션을 정리한다
파괴된 세션의 `user_session_id` 행은 그대로 남는다. **TTL 로 스스로 사라지지만**
바로 치우고 싶으면 브라우저에서 아래를 연다.
```
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/logout
```
**미검증** — 이 실험은 여기까지 재지 않았다.
**확인**
```bash
sudo kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c 'select offline_flag, count(*) from offline_user_session group by 1'
```
**관리 API 호출도 세션을 만들기 때문에 개수에는 노이즈가 있다.** 0 이 안 되어도
놀랄 일이 아니다.
## 5-4. 원상복구 확인표
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| realm | 위 5-1 의 `get realms/...` | `revokeRefreshToken : false` |
| 파드 | `sudo kubectl -n keycloak-lab get pods -l app=keycloak` | 둘 다 `1/1 Running`, `RESTARTS 0` |
| 탐침 | `sudo kubectl -n keycloak-lab get pod b3-probe` | `NotFound` (없어야 정상) |
| BFF 로그인 | 브라우저에서 `https://app1.hyeonworks.com/` | 로그인이 되고 `token 경계 확인` 이 답한다 |
| 밖 | `curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/keycloak-patterns` | `200` |
---
# 막히면
전부 이 실험대가 **실제로 겪은** 증상이거나, 그 기록에서 곧바로 따라 나오는 것이다.
| 증상 | 원인 | 확인 |
|---|---|---|
| **다섯 개가 전부 `200`** | **`&` 를 빼서 순차로 돌았다** — 경합이 안 생긴다 | 루프에 `( ... ) &``wait` 이 있는지 — 4-2 |
| 다섯 개가 전부 `200` (`&` 는 있는데) | **회전이 안 켜졌다** | 3-1 로 다시 읽는다. 3-3 이 `200/400` 이어야 한다 |
| 결과 파일이 비어 있다 | **`wait` 이 없다.** 아직 안 끝난 요청을 읽었다 | `wait` 뒤에 `cat` |
| 출력이 뒤섞여 어느 줄이 어느 요청인지 모른다 | 다섯 개가 같은 터미널에 동시에 쓴다 | 파일로 받고 나중에 읽는다 — 4-2 |
| 전부 `400 invalid_grant` 인데 메시지가 한 종류 | **옛 `RT` 를 계속 썼다** (자기오염) | 갱신마다 `RT` 를 다시 담는다 — 1-5 |
| `kubectl exec keycloak-0 -- curl``exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 탐침 파드를 쓴다 — 1-3 |
| `CS길이=0` | secret 이름/키가 틀렸다 | `get secret bff-secrets -o jsonpath='{.data}'` 로 키 이름만 본다 |
| `{"error":"unauthorized_client"}` | 클라이언트에 direct grant 가 꺼져 있다 | kcadm 으로 `directAccessGrantsEnabled` 확인 |
| kcadm 이 `401` / 아무 말 없이 실패 | 로그인 세션이 만료됐다 | `config credentials` 를 다시 — 1-2 |
| `sid` 가 빈 줄 | base64 패딩 또는 base64url 문자 | `tr '_-' '/+'` 를 넣어 다시 — 1-4 |
| 4-5 에서 행 자체가 없다 | 다른 `SID` 를 넣었다 | 파드 안에서 `echo "$SID"` 를 다시 본다 |
| 회전을 켠 뒤 브라우저 로그인이 이상하다 | **realm 전체에 걸린 설정이다.** BFF 도 영향받는다 | 실험이 끝나면 반드시 5-1 |
---
# 왜 이 가이드에는 부하 도구가 없나
동시성 5는 `ab``k6` 도 필요 없다. **셸의 `&``wait` 이면 충분하고,
그 편이 무엇이 일어났는지 더 잘 보인다** — 요청 다섯 개의 본문을 전부 파일로
갖고 있으니 나중에 다시 읽을 수 있다.
부하 도구는 **개수를 늘려야 할 때** 쓴다. 이 실험이 묻는 것은 개수가 아니라
**「겹치면 무엇이 부서지는가」**이고, 그건 둘만 겹쳐도 답이 나온다.
> **재현에 필요한 최소치를 찾는 것이 실험 설계다.** 다섯 개를 쓴 이유는
> 오류 메시지 두 종류가 한 화면에 같이 보이기 때문이지, 다섯이 필요해서가
> 아니다.
---
# 다음
| 실험 | B-3 이 남긴 질문 |
|---|---|
| [B-5](b5-redis-loss-persistence.md) Redis 상실 | Redis lock 을 쓴다면 **Redis 가 죽었을 때 갱신이 멈춘다.** 그 약점을 직접 본다 |
| [B-2](b2-multi-instance-session.md) 다중 인스턴스 | **왜 두 브라우저가 같은 토큰을 다투는가** — 조회 키가 원인이다 |
| A-6 지연 주입 | 거기서 낙관적 락 충돌이 0 이었던 이유가 여기서 확인된다 — **로그인은 새 행을 만들 뿐**이고, 다투는 것은 **같은 항목을 갱신할 때**다 |
| 설계 | **재시도로 회복되지 않는다 → lock.** 그리고 lock 은 저장소 쪽, 가급적 DB 행 잠금 |