기반 가이드 7단계로 실험대를 철거하고 다시 세운 뒤 virtualization setup 9편과 keycloak-session-store 26편을 순서대로 밟았다. 24편은 끝까지, 11편은 되는 데까지 밟았고 밟은 범위를 편마다 적었다. 명령이 못 도는 것을 고쳤다. - kubectl 을 `kc-lab-1` 에서 치라고 적었는데 그 기계에 kubeconfig 가 없다. 라벨 639개와 각 편의 「어디서 치는가」를 `[lab host]` 로 옮겼다 - `-o custom-columns=…[0]…` 이 zsh 에서 글로브로 읽혀 안 돈다. 28곳에 따옴표 - busybox `sed` 가 끝 개행을 안 붙여 A-3 의 측정이 언제나 0 이었다 - `--token-file ~/node-token` 뒤에 그 파일을 지우면 k3s agent 가 재부팅을 못 견딘다. `/etc/rancher/node-token` 으로 옮기는 처방을 재서 넣었다 - 게스트에 없는 도구를 전제로 한 명령 넷 — `conntrack`·`dig`·`strings`·`nginx -v` - `echo` 와 JWT 헤더가 `"이름" : [ 값 ]` 으로 찍는데 문서는 공백 없이 옮겨 적어 그 실측으로 만든 grep·sed 가 한 줄도 못 잡는다 - B-0 이 `directAccessGrantsEnabled` 와 계정 완성을 빠뜨려 B-3 이 못 돈다 - D-4·D-4a 가 `test-server` 와 `certbot-renew.*` 를 가리키는데 실제로는 `kc-lab-edge` 의 `certbot.service` 다 - `virsh setmaxmem --config` 를 `dominfo` 로 판정하면 틀린다. `--inactive` 로 - `LIBVIRT_DEFAULT_URI` 를 rc 에만 넣으면 `ssh host '명령'` 에서 안 먹는다 결과가 조건부인 것을 갈랐다. - readiness 는 즉시 안 뒤집힌다. A-1·A-2 의 60초 창을 적었다 - 03 의 층 ②③ `301` 은 04 이후의 값이고 그 단계에서는 `404` 다 - A-0 의 로그 필터를 요청 직후에 치면 정반대 결론이 나온다 - A-5 의 한 방향 차단은 잠깐 `1` 이었다 `2` 로 돌아온다 증거는 두 프로젝트의 `evidence/raw/` 에 99벌을 README 와 함께 남겼다. 비밀은 길이만 적었고 화면에 찍힌 토큰은 가렸다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
762 lines
44 KiB
Markdown
762 lines
44 KiB
Markdown
---
|
||
id: 405c4206-9b59-491f-aed1-8b97cfd9f584
|
||
kind: SETUP
|
||
slug: reproduce-b3-refresh-contention
|
||
title: 같은 refresh token 다섯 개를 동시에 던지고 client session 을 센다
|
||
topic: where-application-state-lives
|
||
topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기
|
||
project: keycloak-session-store
|
||
status: 게시 전
|
||
studio: "https://hyeonworks.com/studio/documents/405c4206-9b59-491f-aed1-8b97cfd9f584/edit"
|
||
pinnedVersions:
|
||
- name: curlimages/curl
|
||
version: 8.11.1
|
||
source:
|
||
- final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-3
|
||
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
|
||
---
|
||
|
||
# 같은 refresh token 다섯 개를 동시에 던지고 client session 을 센다
|
||
|
||
같은 refresh token 다섯 개를 동시에 던져 회전 경쟁을 만들고, 이긴 요청이 받은 토큰과 그 세션의 client session 을 세는 절차다. BFF 를 거치지 않고 토큰 엔드포인트를 직접 치며, 되돌리기는 realm 설정 한 줄이다.
|
||
|
||
## 관계
|
||
|
||
- **회전 경쟁에서 이긴 요청의 토큰도 쓸 수 없었다**
|
||
이 절차가 만드는 상태에서 나온 판정이다. 여기는 순서만 적고 무엇이 부서졌는지는 그쪽이 적는다.
|
||
- **기본키에 세션 id 가 없어서 두 번째 로그인이 첫 토큰을 덮어썼다**
|
||
두 replica 가 같은 행을 본다는 것이 이 경쟁의 전제다. 그 전제를 만든 편이다.
|
||
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
|
||
다섯이 전부 `200` 일 때 그것이 「경쟁이 없었다」인지 「주입이 안 걸렸다」인지를 가르는 기준이다.
|
||
- **토큰을 PostgreSQL 로 옮기고 기본키와 로그아웃 정리를 확인한다**
|
||
먼저 해 둬야 하는 편이다. 토큰이 공유되어야 경쟁이 성립한다.
|
||
|
||
## 본문
|
||
|
||
<!-- body:start -->
|
||
|
||
## 읽기 전에 — 어디서 치는가
|
||
|
||
명령은 `[lab host]` 에서 `kubectl` 로 친다. 토큰을 주고받는 `curl` 만 탐침 파드 안에서 치는데, Keycloak 이미지에 `curl` 도 `wget` 도 없기 때문이다(`exit 127`). 브라우저는 필요 없다 — direct grant(`grant_type=password`)로 토큰을 만들므로 전 구간이 터미널에서 끝난다.
|
||
|
||
**원 가이드는 이 명령들을 `kc-lab-1` 에서 치라고 적었다.** 기반 가이드가 세운 실험대에서는 그 기계에 kubeconfig 가 없어서 `sudo` 없는 `kubectl` 이 `permission denied` 로 막힌다 — kubeconfig 는 lab host 의 `~/.kube/config` 에만 있다(2026-09-17 에 양쪽에서 쳐서 확인했다, observed). 그래서 `kubectl` 블록의 기계 이름을 `[lab host]` 로 적었고, 노드 자체를 건드리는 명령에만 게스트 셸을 쓴다.
|
||
|
||
터미널은 둘을 연다. 하나는 탐침 파드 셸을 붙잡고 있고, 다른 하나로 데이터베이스를 뒤진다. 파드 안에서 잡은 `RT` 와 `SID` 는 파드 밖으로 따라가지 않는다.
|
||
|
||
| 무엇 | 값 |
|
||
|---|---|
|
||
| 네임스페이스 | `keycloak-lab` |
|
||
| realm · 사용자 · 클라이언트 | `keycloak-patterns` · `labuser` / `labpass` · `bff-confidential` |
|
||
| 주입 수단 | `revokeRefreshToken=true` — realm 전체에 걸린다 |
|
||
| 탐침 파드 | `b3-probe` — `curlimages/curl:8.11.1`, `sleep 7200`, `--restart=Never` |
|
||
| 치는 곳 | 토큰 엔드포인트를 직접. BFF 를 거치지 않는다 |
|
||
| 동시성 | 다섯. 셸의 `&` 와 `wait` 으로 만든다 |
|
||
| 전 구간 | 약 20분 |
|
||
| 도구 | `jq` 가 이 실험대에 없다. JSON 은 `sed` 로 자른다 |
|
||
|
||
## 이 실험이 가르는 것
|
||
|
||
B-2 가 토큰을 PostgreSQL 로 옮겼고 두 replica 가 같은 행을 본다. 조회 키에 세션 id 가 없으니 같은 사용자의 두 브라우저도 같은 행을 본다. 그 행에는 refresh token 이 하나 들어 있다. 둘이 동시에 그 하나를 갱신하면 무슨 일이 일어나는가.
|
||
|
||
통념은 하나가 성공하고 하나가 실패하며, 실패한 쪽은 새 토큰을 다시 읽어 재시도하면 된다고 본다. 이 절차는 진짜 그런지와, 이긴 쪽은 멀쩡한지를 잰다.
|
||
|
||
```text
|
||
실패가 사용자에게 안 보인다 → 재시도로 덮으면 된다
|
||
실패가 사용자에게 보인다 → 애초에 겹치지 않게 lock 을 걸어야 한다
|
||
```
|
||
|
||
그래서 재야 할 것은 몇 개가 성공했나가 아니라 **이긴 요청의 토큰을 다시 쓸 수 있나**다.
|
||
|
||
재사용 탐지(reuse detection)가 배경에 있다. 회전이 켜져 있으면 새 refresh token 을 줄 때 옛 것을 무효화하는데, 무효화된 옛 토큰이 다시 들어오면 두 가지 중 하나다.
|
||
|
||
```text
|
||
① 정상 클라이언트가 응답을 못 받아 재시도했다 (무해)
|
||
② 토큰이 유출되어 공격자가 쓰고 있다 (치명)
|
||
```
|
||
|
||
서버는 둘을 구별할 수 없다. 그래서 OAuth 2.0 보안 권고는 안전한 쪽으로 가정하고 세션 전체를 무효화하라고 말한다. 이 절차가 보는 파괴는 버그가 아니라 규격이 시키는 대로 동작한 결과이고, 그래서 답이 「고쳐 달라」가 아니라 「겹치지 않게 하라」가 된다.
|
||
|
||
끝까지 밟으면 다섯 중 하나만 `200` 이고 나머지가 `400` 인 것, 오류 문구가 두 종류인 것, 이긴 요청이 받은 토큰조차 못 쓰는 것, user session 은 남고 client session 만 사라진 것, `refreshTokenMaxReuse` 를 올려도 안 되는 것을 자기 화면에서 보게 된다.
|
||
|
||
## 전제와 되돌리기
|
||
|
||
- `05-keycloak` · `06-observability` 가 끝나 있다.
|
||
- **B-2 가 끝나 있다.** 토큰이 공유되어야 경쟁이 성립한다. 다만 이 절차는 Keycloak 쪽 동작만 갈라 보려고 BFF 를 거치지 않고 토큰 엔드포인트를 직접 친다.
|
||
|
||
**realm 설정을 바꾸는 실험이다.** `revokeRefreshToken` 을 켜면 realm 전체에 걸리고, 같은 realm 을 쓰는 다른 작업이 영향을 받는다. B-2 의 BFF 로그인도 그 안에 든다. 실험대에서만 하고, 중간에 그만두려면 아래 한 줄이면 된다.
|
||
|
||
```bash label="[lab host] 중간에 그만둘 때 치는 한 줄"
|
||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
update realms/keycloak-patterns -s revokeRefreshToken=false -s refreshTokenMaxReuse=0
|
||
```
|
||
|
||
## 주입 전에 같은 명령으로 먼저 본다
|
||
|
||
시험군만 재는 측정은 측정이 아니다. 회전이 꺼진 상태에서 같은 명령을 먼저 돌려 두어야, 나중에 나오는 `400` 이 원래 그런 것인지 내가 켠 것 때문인지 갈린다.
|
||
|
||
```text
|
||
파드 → realm 설정 → 탐침 파드 → 토큰 하나 → 대조군(순차) → 대조군(정상 세션)
|
||
```
|
||
|
||
### 1. Keycloak 이 둘 다 Ready 인가
|
||
|
||
**무엇을 보는가** — 파드 셋의 상태와 배치.
|
||
|
||
```bash label="[lab host] 파드 배치를 본다"
|
||
kubectl -n keycloak-lab get pods -o wide
|
||
```
|
||
|
||
**어디를 보나** — 모양은 이렇다(observed).
|
||
|
||
```text
|
||
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 를 넘는 경쟁이라고 말할 수 없게 된다.
|
||
|
||
### 2. kcadm 에 로그인해 둔다
|
||
|
||
**목적** — realm 설정을 읽고 바꾸는 명령을 쓸 수 있게 한다.
|
||
|
||
① 파드 안에서 관리 세션을 만든다. 비밀번호는 명령 치환으로 넘기므로 값이 터미널에도 셸 히스토리에도 안 남는다.
|
||
|
||
```bash label="[lab host] kcadm 관리 세션을 만든다"
|
||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
config credentials --server http://localhost:8080 --realm master --user admin \
|
||
--password "$(kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)"
|
||
```
|
||
|
||
**예상 결과** — 성공하면 아무것도 안 나온다.
|
||
|
||
**왜 필요한가** — 한 번 하면 파드 안에 세션이 남아 뒤의 `get` · `update` 가 전부 그것을 쓴다.
|
||
|
||
**문제가 생기면** — 비밀번호가 실제로 있는지는 값이 아니라 길이로 본다.
|
||
|
||
```bash label="[lab host] 비밀번호의 길이만 센다"
|
||
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
|
||
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
|
||
```
|
||
|
||
### 3. realm 의 세 값을 읽는다
|
||
|
||
**무엇을 보는가** — 주입이 건드릴 스위치와 건드리지 않을 값.
|
||
|
||
```bash label="[lab host] realm 의 세 값을 읽는다"
|
||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
get realms/keycloak-patterns \
|
||
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
|
||
```
|
||
|
||
**어디를 보나** — 실측은 이렇다(observed).
|
||
|
||
```json
|
||
{ "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
|
||
```
|
||
|
||
| 값 | 뜻 | 지금 |
|
||
|---|---|---|
|
||
| `revokeRefreshToken` | 회전 스위치 | `false` — 꺼져 있다 |
|
||
| `refreshTokenMaxReuse` | 회전이 켜졌을 때 몇 번까지 봐줄 것인가 | `0` |
|
||
| `accessTokenLifespan` | access token 수명(초) | `60` |
|
||
|
||
**이 값이 뜻하는 것** — 기본값은 회전이 꺼져 있다. 「회전과 재사용 허용 0회를 쓰는 realm」이 재려는 상태이므로, 그 상태를 만드는 것이 이 절차의 주입이다. 지금 그대로 재면 다른 것을 재게 된다. `accessTokenLifespan=60` 은 B-0 이 이 실험을 위해 넣어 둔 값이고, 만료를 기다리는 시간이 짧아야 재현이 된다. 이 값은 주입이 끝난 뒤에도 `60` 이어야 한다.
|
||
|
||
### 4. 상주 탐침 파드를 띄운다
|
||
|
||
**목적** — 발급받은 토큰을 다음 단계로 넘길 수 있는 셸을 만든다.
|
||
|
||
`--rm` 임시 파드는 매번 만들고 지우므로 토큰을 단계 사이로 못 넘긴다. 이 실험은 앞 단계에서 받은 토큰을 뒤 단계에서 써야 하므로 파드를 하나 띄워 두고 `exec` 로 이어간다.
|
||
|
||
**중간에 그만뒀다가 다시 시작하는 것이면 먼저 지운다.** `b3-probe` 라는 이름이 이미 있으면 아래 `run` 은 그 이름이 이미 있다며 거절하고, 남아 있는 파드가 들고 있는 `KC` 와 `CS` 는 지난번에 넣은 값이다. 지우는 명령은 이 절 끝 「문제가 생기면」에 있다.
|
||
|
||
① 파드를 띄우고 Ready 까지 기다린다.
|
||
|
||
```bash label="[lab host] ① 상주 탐침 파드를 띄운다"
|
||
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=$(kubectl -n keycloak-lab get secret bff-secrets \
|
||
-o jsonpath='{.data.KEYCLOAK_CLIENT_SECRET}' | base64 -d)" \
|
||
--command -- sleep 7200
|
||
kubectl -n keycloak-lab wait --for=condition=Ready pod/b3-probe --timeout=120s
|
||
```
|
||
|
||
② 환경변수가 들어갔는지 값이 아니라 길이로 본다.
|
||
|
||
```bash label="[lab host] ② 넘어간 값의 길이를 센다"
|
||
kubectl -n keycloak-lab exec b3-probe -- sh -c 'echo "KC=$KC CS길이=${#CS}"'
|
||
```
|
||
|
||
**그 길이를 `15` 와 맞추려 하지 않는다.** 아래 실측의 `15` 는 그 실험대의 값이고, B-0 이 적은 `-s secret=bff-lab-secret` 을 쓰면 `14` 다(2026-09-17, observed). 볼 것은 숫자가 아니라 **매니페스트 쪽과 Keycloak 쪽이 같은가**이고, B-0 이 「이 절차에는 둘을 견주는 단계가 없다」고 적어 둔 그 대조가 이것이다. 값을 안 찍고 견주는 형태는 이렇다.
|
||
|
||
```bash label="[lab host] 두 값이 같은지 값을 안 찍고 본다"
|
||
A=$(kubectl -n keycloak-lab get secret bff-secrets \
|
||
-o jsonpath='{.data.KEYCLOAK_CLIENT_SECRET}' | base64 -d)
|
||
C=$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
get clients -r keycloak-patterns -q clientId=bff-confidential \
|
||
--fields id --format csv --noquotes | tr -d '\r')
|
||
S=$(kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
get clients/$C/client-secret -r keycloak-patterns \
|
||
--fields value --format csv --noquotes | tr -d '\r')
|
||
echo "매니페스트 ${#A}자 · Keycloak ${#S}자"
|
||
[ "$A" = "$S" ] && echo "두 값이 같다" || echo "두 값이 다르다"
|
||
```
|
||
|
||
2026-09-17 실측은 `매니페스트 14자 · Keycloak 14자` 와 `두 값이 같다` 였다(observed).
|
||
|
||
**여기서 한 번에 막히는 것이 둘 더 있다.** 둘 다 원인이 B-0 에 있고 B-0 을 끝까지 밟아도 안 드러난다 — B-0 의 로그인은 인가 코드 흐름이라 둘 다 필요 없기 때문이다(2026-09-17, observed).
|
||
|
||
| 5번에서 나오는 것 | 무엇이 빠졌나 | 어디서 고치나 |
|
||
|---|---|---|
|
||
| `unauthorized_client` · `Client not allowed for direct access grants` | 클라이언트의 `directAccessGrantsEnabled` 가 `false` | B-0 의 클라이언트 생성에 `-s directAccessGrantsEnabled=true` |
|
||
| `invalid_grant` · `Account is not fully set up` | `labuser` 의 `emailVerified` 가 `false` 이고 이메일·이름 칸이 비었다 | B-0 의 사용자 생성 뒤 `-s emailVerified=true -s email=… -s firstName=… -s lastName=…` |
|
||
|
||
둘을 고친 뒤 같은 요청이 토큰을 돌려줬다(observed).
|
||
|
||
③ 파드 셸로 들어간다. 프롬프트가 `/ $` 로 바뀐다.
|
||
|
||
```bash label="[lab host] ③ 파드 셸로 들어간다"
|
||
kubectl -n keycloak-lab exec -it b3-probe -- sh
|
||
```
|
||
|
||
**예상 결과** — ①은 `pod/b3-probe condition met`, ②는 아래 모양이다(observed).
|
||
|
||
```text
|
||
KC=http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns/protocol/openid-connect/token CS길이=15
|
||
```
|
||
|
||
**왜 필요한가** — 요청이 Service 로 간다. A-1·A-2 는 어느 노드가 답했나가 질문이라 파드 IP 로 직접 쳤지만, 여기는 replica 를 넘는 경쟁이 질문이므로 Service 가 요청을 흩는 것이 오히려 필요한 조건이다. `--rm` 이 없으므로 `exit` 해도 파드는 안 지워지고, 지우는 명령은 복구 절에 있다.
|
||
|
||
**문제가 생기면** — `CS길이=0` 이면 `--env` 가 빈 값을 넘겼다. 파드를 지우고 다시 띄운다.
|
||
|
||
```bash label="[lab host] 탐침 파드를 지운다"
|
||
kubectl -n keycloak-lab delete pod b3-probe --ignore-not-found
|
||
```
|
||
|
||
### 5. 토큰을 하나 받는다
|
||
|
||
**무엇을 보는가** — 응답에 무엇이 들어 있는지. 나중에 걸러 보려면 먼저 통째로 봐야 한다.
|
||
|
||
```sh label="[탐침 파드] ① 응답을 통째로 본다"
|
||
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 이 나온다(모양은 observed).
|
||
|
||
```json
|
||
{"access_token":"eyJhbGciOi...","expires_in":60,"refresh_expires_in":1800,
|
||
"refresh_token":"eyJhbGciOi...","token_type":"Bearer","scope":"openid profile email"}
|
||
```
|
||
|
||
`expires_in` 이 60 이다 — 앞에서 읽은 `accessTokenLifespan` 그대로다. 여기가 `{"error":"unauthorized_client"}` 면 클라이언트에 direct grant 가 꺼진 것이고, `{"error":"invalid_grant"}` 면 사용자 이름이나 비밀번호다.
|
||
|
||
**다음에 쓸 값을 변수에 담는다.** 원래 실행도 이 형태였다(observed).
|
||
|
||
```sh label="[탐침 파드] ② 변수에 담고 sid 를 뽑는다"
|
||
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"
|
||
```
|
||
|
||
실측은 토큰 길이 `811`, jti `8e7e3ee2-0dc8-573d-58ec-d12651a50b9c`, sid `BvFiB01Rntz1FcLdf7zG4BNt` 다(observed).
|
||
|
||
**`SID` 를 종이에 적어 둔다.** 관찰 절에서 데이터베이스를 뒤질 때 이 값이 필요하고, 그때는 파드 밖이라 변수가 안 넘어간다.
|
||
|
||
`sid` 가 빈 줄로 나오면 base64 패딩이나 base64url 문자(`-` `_`) 때문이다. 위 ②는 패딩만 채우고 아래 줄은 base64url 문자만 바꾸므로, 둘 중 하나씩만 고치는 셈이다. 둘을 한 줄에 같이 넣은 형태는 원본 가이드에 없다(unknown). 아래 형태로 페이로드 전체를 찍고 그 안에서 `"sid"` 를 눈으로 찾아 손으로 옮기는 것이 이 문서에 있는 방법이다. 가이드가 이 줄을 미검증으로 표시했다(unknown).
|
||
|
||
```sh label="[탐침 파드] sid 가 안 나올 때 페이로드를 통째로 찍는다"
|
||
echo "$R" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p' \
|
||
| cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
|
||
```
|
||
|
||
### 6. 대조군 하나 — 순차로 다섯 번 갱신한다
|
||
|
||
**무엇을 보는가** — 겹치지 않으면 무슨 일이 일어나는지. 파드 안에서 `&` 없이 친다.
|
||
|
||
```sh label="[탐침 파드] 순차로 다섯 번 갱신한다"
|
||
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` 이어야 한다. 증거 파일에는 순차 실행 기록이 없고(unknown), 해설 문서가 순차 실행이면 재현되지 않는다고 말한다. 따라 하는 사람이 자기 손으로 확인하는 순서다.
|
||
|
||
**이 값이 뜻하는 것** — 루프가 갱신마다 `RT` 를 다시 담는다. 회전이 켜지면 옛 것을 계속 쓸 수 없고, 그대로 두면 뒤에 나오는 `400` 이 경쟁 때문인지 옛 토큰을 썼기 때문인지 갈리지 않는다. 이 실험에서 가장 흔한 자기오염이다.
|
||
|
||
### 7. 대조군 둘 — 경쟁을 겪지 않은 세션의 모양
|
||
|
||
**무엇을 보는가** — 정상 세션의 `client_sessions` 가 몇인가. 파드 밖에서 친다.
|
||
|
||
```bash label="[lab host] 정상 세션의 client session 을 센다"
|
||
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'"
|
||
```
|
||
|
||
**어디를 보나** — 실측은 이렇다(observed, `03-client-session-removed.txt`).
|
||
|
||
```text
|
||
=== 대조: 정상 세션 하나를 새로 만들어 비교 ===
|
||
새 sid: JT-XuepgutWcE273QwAnIXta
|
||
user_session_id | client_sessions
|
||
--------------------------+-----------------
|
||
JT-XuepgutWcE273QwAnIXta | 1
|
||
(1 row)
|
||
```
|
||
|
||
**이 값이 뜻하는 것** — `client_sessions = 1` 이 정상 세션의 모양이다. 위 질의의 sid 는 원래 실행의 값이므로 따라 하는 사람은 자기 `SID` 를 넣는다. 안 바꾸고 치면 질의는 오류 없이 성공하고 `(0 rows)` 만 돌아온다. 이 대조군이 없으면 나중에 나오는 `0` 이 경쟁 때문인지 원래 그런 표인지 모른다.
|
||
|
||
user session 과 client session 은 서로 다르다.
|
||
|
||
```text
|
||
user session "이 브라우저는 labuser 로 로그인함"
|
||
├─ client session : bff-confidential
|
||
└─ client session : oauth2-proxy
|
||
```
|
||
|
||
사용자가 한 번 로그인하고 여러 애플리케이션에 들어가면 user session 하나 아래에 client session 이 여럿 달린다. 그게 SSO 다. 재사용 탐지는 이 중 client session 만 제거한다. 온라인 세션인데 표 이름이 `offline_user_session` 인 것이 헷갈리는데, `offline_flag` 열이 그것을 가른다 — 위 출력의 `offline_flag = 0` 이 온라인 세션이다.
|
||
|
||
## 주입
|
||
|
||
**목적** — 회전과 재사용 허용 0회를 켠다.
|
||
|
||
`revokeRefreshToken` 이 회전 스위치다. 이름이 회전(rotation)이 아니라 취소(revoke)인데, 켜면 새 토큰을 줄 때 옛 토큰을 무효화하고 그 결과가 회전이다.
|
||
|
||
| 설정 | 뜻 |
|
||
|---|---|
|
||
| `revokeRefreshToken` | 회전 스위치. 켜면 새 토큰 발급 시 옛 토큰을 무효화 |
|
||
| `refreshTokenMaxReuse` | 그 위에서 몇 번까지 봐줄 것인가 |
|
||
|
||
`refreshTokenMaxReuse` 는 `revokeRefreshToken` 이 켜져야 의미가 있다. 꺼진 상태에서 이 값만 올리면 아무 일도 안 일어난다 — 무효화 자체가 없으니 봐줄 횟수를 셀 대상이 없다. 관리 콘솔에서 이 항목이 회색으로 보이는 까닭도 거기 있다.
|
||
|
||
① 회전을 켜고 시각을 남긴다.
|
||
|
||
```bash label="[lab host] 회전을 켜고 시각을 남긴다"
|
||
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 회전 켬'
|
||
```
|
||
|
||
**예상 결과** — 성공하면 아무 말도 안 하고 시각만 찍힌다(모양은 observed).
|
||
|
||
```text
|
||
14:16:12 회전 켬
|
||
```
|
||
|
||
**왜 필요한가** — 관찰 절의 결과를 이 시각 이후에 만든 토큰으로 재야 한다. 켜기 전에 발급한 토큰으로 재면 발급 시점의 정책이 아니라 검증 시점의 정책이 적용되어 섞이고, 그러면 해석이 안 된다.
|
||
|
||
**문제가 생기면** — 주입 검증으로 넘어가 설정을 다시 읽는다.
|
||
|
||
## 주입 검증
|
||
|
||
결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 본다. 설정은 똑같은 명령으로 다시 읽는다.
|
||
|
||
```bash label="[lab host] ① realm 을 똑같은 명령으로 다시 읽는다"
|
||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
get realms/keycloak-patterns \
|
||
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
|
||
```
|
||
|
||
모양은 이렇다(observed).
|
||
|
||
```json
|
||
{ "revokeRefreshToken" : true, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
|
||
```
|
||
|
||
`revokeRefreshToken` 이 `true` 여야 한다. `false` 그대로면 `update` 가 다른 realm 에 갔거나 kcadm 세션이 만료됐다. kcadm 은 실패해도 조용할 때가 있어 반드시 다시 읽어서 확인한다.
|
||
|
||
```bash label="[lab host] ② 재시작이 올랐는지 본다"
|
||
kubectl -n keycloak-lab get pods -l app=keycloak
|
||
```
|
||
|
||
`RESTARTS` 가 여전히 0 이어야 한다. realm 설정 변경은 재시작을 일으키지 않으므로, 여기서 재시작이 올랐다면 다른 것을 건드렸다. 그 상태로 재면 경쟁이 아니라 재시작을 재게 된다.
|
||
|
||
**동시성을 넣기 전에 회전 자체가 도는지 확인한다.** 새 토큰을 하나 받고 한 번 갱신한 뒤 옛 것을 다시 쓴다. 가이드는 이 단계를 증거 파일에 없는 사전 확인이라고 적는다(unknown).
|
||
|
||
```sh label="[탐침 파드] ③ 회전이 실제로 도는지 두 번 쳐서 본다"
|
||
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` 이면 회전이 안 켜진 것이고, 그 상태로 관찰 절을 돌리면 다섯 개가 전부 `200` 으로 나온다 — 그건 경쟁이 없었다는 뜻이 아니라 주입이 안 걸렸다는 뜻이다.
|
||
|
||
## 관찰
|
||
|
||
사전 확인에서 쓴 토큰은 이미 무효다. 깨끗한 토큰을 하나 새로 받고 `SID` 를 다시 적어 둔다.
|
||
|
||
```sh label="[탐침 파드] ① 깨끗한 토큰을 새로 받는다"
|
||
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"
|
||
```
|
||
|
||
**동시에 다섯 개를 던진다.** 원래 실행은 스크립트였고, 아래는 가이드가 손으로 치기 좋게 고친 형태다. **2026-09-17 에 그대로 쳐서 돌았다**(observed). 본문과 응답 코드를 파일로 갈라 순서대로 다시 읽게 했다.
|
||
|
||
```sh label="[탐침 파드] ② 같은 토큰으로 동시에 다섯 번 갱신한다"
|
||
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
|
||
```
|
||
|
||
셸 문법 세 조각이 전부다.
|
||
|
||
```text
|
||
( ... ) & 서브셸을 백그라운드로 띄운다 → 다섯 개가 동시에 난다
|
||
wait 띄운 것이 전부 끝날 때까지 기다린다
|
||
> /tmp/c$i 각자 자기 파일에 쓴다 → 출력이 안 섞인다
|
||
```
|
||
|
||
`&` 를 빼면 while 루프가 하나씩 기다리고, 그러면 이 실험은 재현되지 않는다. `wait` 을 빼면 결과 파일을 읽을 때 아직 안 끝난 것이 있어 빈 줄이 나온다. 다섯 개가 같은 터미널에 동시에 쓰면 어느 줄이 어느 요청인지 알 수 없어서 파일로 받고 `wait` 뒤에 순서대로 읽는다.
|
||
|
||
실측은 이렇다(observed, `01-concurrent-refresh.txt`).
|
||
|
||
```text
|
||
=== [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 이 이미 없다 |
|
||
|
||
**2026-09-17 실측**(observed) — 같은 모양이 다시 나왔다.
|
||
|
||
```text
|
||
요청 1: HTTP 400 Session doesn't have required client
|
||
요청 2: HTTP 400 Session doesn't have required client
|
||
요청 3: HTTP 400 Maximum allowed refresh token reuse exceeded
|
||
요청 4: HTTP 200 (발급됨)
|
||
요청 5: HTTP 400 Session doesn't have required client
|
||
이긴 요청의 새 refresh 길이: 810
|
||
그 토큰을 다시 쓰면: 400 Session doesn't have required client
|
||
```
|
||
|
||
**몇 번 요청이 이기는지는 실행마다 다르다.** 위 실측에서는 5번이었고 2026-09-17 에는 4번이었다. 판정에 쓰는 것은 번호가 아니라 **`200` 이 하나이고 그 하나가 받은 토큰도 안 통한다**는 두 가지다.
|
||
|
||
하나만 이기고 나머지가 진 것이라면 지는 쪽 메시지가 전부 같아야 한다. 두 종류라는 것은 중간에 상태가 바뀌었다는 뜻이다. 성공한 번호는 환경마다 다르고 증거에서는 5번이었지만 순서는 스케줄링이 정한다 — 몇 번이 이겼는가는 아무 의미가 없다.
|
||
|
||
**이긴 요청의 토큰을 다시 써 본다.** 여기서 진짜 답이 나온다.
|
||
|
||
```sh label="[탐침 파드] ③ 이긴 요청이 받은 토큰을 꺼내 다시 쓴다"
|
||
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"
|
||
```
|
||
|
||
실측은 이렇다(observed, `02-session-impact.txt`).
|
||
|
||
```text
|
||
=== [3] 이긴 요청이 받은 새 토큰은 쓸 수 있는가 ===
|
||
새 refresh token 길이: 810
|
||
그 토큰으로 다시 갱신: HTTP 400
|
||
{"error":"invalid_grant","error_description":"Session doesn't have required client"}
|
||
```
|
||
|
||
이긴 요청조차 쓸 수 없는 토큰을 받았다.
|
||
|
||
```text
|
||
애플리케이션이 본 것 : HTTP 200 + 새 토큰 → "성공했다"
|
||
실제 상태 : 세션이 이미 없다 → 다음 요청에서 끊긴다
|
||
```
|
||
|
||
오류가 지연되어 나타난다. `200` 을 받은 코드는 성공했다고 믿고 토큰을 저장하고, 끊긴 것은 그다음 요청에서 안다. 로그를 볼 때 원인 시각과 증상 시각이 어긋나 보이는 까닭도 여기 있다. 재시도하면 되지 않나가 여기서 무너진다 — 새 토큰을 다시 읽어 재시도해도 그 토큰이 이미 무효라 재시도할 대상이 없다.
|
||
|
||
**무엇이 사라졌는지는 데이터베이스가 말한다.** 파드 밖에서 치고, sid 는 앞에서 적어 둔 값을 넣는다.
|
||
|
||
**아래 두 블록에 박힌 `'BvFiB01Rntz1FcLdf7zG4BNt'` 를 자기 `SID` 로 바꾼다.** 그것은 원래 실행의 sid 라, 그대로 붙여넣으면 질의는 오류 없이 성공하고 `(0 rows)` 만 돌아온다. 두 블록 모두 바꿔야 한다 — 한쪽만 바꾸면 두 출력이 서로 다른 세션을 말한다. 출력은 마지막 줄부터 읽는다. `(1 row)` 면 그 sid 의 세션을 찾았고, `(0 rows)` 면 sid 를 안 바꿨거나 다른 값을 넣었다.
|
||
|
||
```bash label="[lab host] ④ 그 sid 의 세션이 남아 있는가"
|
||
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'"
|
||
```
|
||
|
||
실측은 이렇다(observed, `02-session-impact.txt`).
|
||
|
||
```text
|
||
=== [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 label="[lab host] ⑤ 같은 sid 의 client session 을 센다"
|
||
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'"
|
||
```
|
||
|
||
실측은 이렇다(observed, `03-client-session-removed.txt`).
|
||
|
||
```text
|
||
=== user session 과 client session 을 나눠서 본다 ===
|
||
user_session_id | offline_flag | client_sessions
|
||
--------------------------+--------------+-----------------
|
||
BvFiB01Rntz1FcLdf7zG4BNt | 0 | 0
|
||
(1 row)
|
||
```
|
||
|
||
**`(0 rows)` 와 위 출력의 `client_sessions 0` 은 다른 답이다.** 앞은 그 sid 의 행을 아예 못 찾았다는 뜻이고, 뒤는 행을 찾았는데 그 안의 개수가 0 이다. 화면에서 `0` 두 개가 비슷해 보이지만 판정은 뒤에서만 나온다.
|
||
|
||
`client_sessions = 0` 이고 대조군은 `1` 이었다. 같은 명령에 다른 결과가 나온 것이 이 실험의 판정이다.
|
||
|
||
```text
|
||
user session "이 브라우저는 labuser 로 로그인함" ← 남는다
|
||
└─ client session "그중 bff-confidential 에 대한 상태" ← 지워졌다
|
||
```
|
||
|
||
오류 문구가 정확히 그 말을 한다 — 세션은 있는데 그 클라이언트 몫이 없다. 메시지를 오해해서 세션이 만료됐다로 읽으면 엉뚱한 곳을 고치게 된다.
|
||
|
||
폐기 목록에 실린 것도 아니다.
|
||
|
||
```bash label="[lab host] ⑥ 폐기 목록을 센다"
|
||
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak -c \
|
||
"select count(*) as revoked_count from revoked_token"
|
||
```
|
||
|
||
실측은 이렇다(observed, `02-session-impact.txt`).
|
||
|
||
```text
|
||
=== [5] revoked_token 테이블 ===
|
||
revoked_count
|
||
---------------
|
||
0
|
||
(1 row)
|
||
```
|
||
|
||
`0` 이다. 토큰을 블랙리스트에 올려서 막은 것이 아니라 client session 이 사라져서 검증할 대상이 없어졌다. 토큰을 지우는 방식이었다면 다른 토큰은 살아 있어야 하는데, 여기서는 그 client 에 대한 모든 토큰이 한꺼번에 죽는다.
|
||
|
||
왜 이긴 쪽도 죽는지는 시간선이 말한다.
|
||
|
||
```text
|
||
t0 5개가 동시에 도착
|
||
t1 하나가 처리를 시작 → 새 토큰 발급 준비
|
||
t2 다른 것들이 같은 옛 토큰으로 들어옴 → 재사용 탐지 발동
|
||
t3 ★ client session 제거
|
||
t4 t1 의 응답이 나간다 → HTTP 200, 새 토큰
|
||
t5 그 토큰을 쓰면 → client session 이 없다 → 400
|
||
```
|
||
|
||
t3 와 t4 의 순서가 전부다. 응답을 만들던 요청은 이미 성공이 확정된 상태로 나가고, 그 사이 바닥이 빠진다.
|
||
|
||
**정책을 바꿔 두 번 더 잰다.** 한 번 더 재기 전에 세션을 새로 만든다 — 파괴된 세션으로 재면 전부 `400` 이다.
|
||
|
||
```bash label="[lab host] ⑦ 구성 B — 회전을 끈다"
|
||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
update realms/keycloak-patterns -s revokeRefreshToken=false
|
||
```
|
||
|
||
관찰 절의 ① 새 토큰 발급 → ② 동시 다섯 개 → ③ 이긴 토큰 재사용 → ⑤ client session 세기를 그 순서대로 다시 친다. ①·②·③ 은 탐침 파드 셸 안에서 치고 ⑤ 는 파드 밖에서 친다. 스크롤백을 되돌려 치는 것으로는 안 된다 — ①이 새 `SID` 를 만들고, ⑤의 질의에는 방금 만든 그 값을 넣어야 한다.
|
||
|
||
```text
|
||
=== 구성 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` 이고 세션도 멀쩡하다(observed, `04-policy-comparison.txt`). 같은 refresh token 을 계속 쓸 수 있으므로 경쟁 자체가 성립하지 않는다. 대신 잃는 것이 있다 — 토큰이 유출되면 만료까지 계속 쓸 수 있고, 회전의 목적이 그 창을 좁히는 것이었다.
|
||
|
||
```bash label="[lab host] ⑧ 구성 C — 회전을 켜고 재사용 1회를 허용한다"
|
||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
update realms/keycloak-patterns -s revokeRefreshToken=true -s refreshTokenMaxReuse=1
|
||
```
|
||
|
||
```text
|
||
=== 구성 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` 은 그대로다(observed, 같은 파일).
|
||
|
||
| 구성 | 성공 | 이긴 토큰 재사용 | 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` 이어야 하는데, 그러면 회전의 보안 목적이 사라진다. 값을 올려 버티려는 시도는 몇 개까지 동시에 올 것인가를 맞춰야 하는 문제로 바뀔 뿐이고, 그 답은 아무도 모른다.
|
||
|
||
그래서 답은 잠금이고, 잠금은 저장소 쪽에 있어야 한다 — 프로세스 안의 `synchronized` 는 replica 를 넘지 못한다.
|
||
|
||
| 후보 | |
|
||
|---|---|
|
||
| PostgreSQL 행 잠금 | `SELECT ... FOR UPDATE` — A-0 에서 Keycloak 자신이 쓰는 방식 |
|
||
| Redis 분산 잠금 | `SET NX PX` — TTL 로 스스로 풀린다 |
|
||
| 갱신 전용 인스턴스 | 단일 지점. 그 인스턴스가 죽으면? |
|
||
|
||
데이터베이스 잠금은 잠금의 수명이 연결의 수명과 묶인다. 프로세스가 죽으면 연결이 끊기고 잠금은 자동으로 풀린다. Redis 잠금은 TTL 이 짧으면 중복 갱신, 길면 정지이고, 그 약점은 B-5 에서 다시 만난다.
|
||
|
||
## 복구와 원상복구 확인표
|
||
|
||
### 1. realm 설정을 되돌린다
|
||
|
||
**목적** — 같은 realm 을 쓰는 다른 작업이 회전을 물려받지 않게 한다.
|
||
|
||
① 두 값을 한 번에 되돌리고 시각을 남긴다.
|
||
|
||
```bash label="[lab host] ① 회전을 끄고 시각을 남긴다"
|
||
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 회전 끔'
|
||
```
|
||
|
||
② 똑같은 명령으로 다시 읽는다.
|
||
|
||
```bash label="[lab host] ② 되돌아갔는지 다시 읽는다"
|
||
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
|
||
get realms/keycloak-patterns \
|
||
--fields revokeRefreshToken,refreshTokenMaxReuse,accessTokenLifespan
|
||
```
|
||
|
||
**예상 결과** — 주입 전에 읽은 세 값과 전부 같아야 한다(모양은 observed).
|
||
|
||
```json
|
||
{ "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }
|
||
```
|
||
|
||
**왜 필요한가** — 구성 C 에서 `refreshTokenMaxReuse` 를 `1` 로 올렸으므로 그것까지 같이 되돌려야 한다. `accessTokenLifespan` 이 60 이 아니면 다른 것도 건드렸다.
|
||
|
||
**문제가 생기면** — kcadm 세션이 만료됐을 수 있다. `config credentials` 를 다시 친다.
|
||
|
||
### 2. 탐침 파드를 지우고 세션을 정리한다
|
||
|
||
**목적** — 실험 도구를 치우고 파괴된 세션을 남기지 않는다.
|
||
|
||
① 파드를 직접 지운다. `--rm` 이 없으므로 자동으로 사라지지 않는다.
|
||
|
||
```bash label="[lab host] ① 탐침 파드를 지운다"
|
||
kubectl -n keycloak-lab delete pod b3-probe --ignore-not-found
|
||
```
|
||
|
||
② 파괴된 세션의 행은 TTL 로 스스로 사라진다. 바로 치우고 싶으면 브라우저에서 아래를 연다. 이 실험은 여기까지 재지 않았다(unknown).
|
||
|
||
```text
|
||
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/logout
|
||
```
|
||
|
||
③ 세션 수를 센다.
|
||
|
||
```bash label="[lab host] ③ 세션 수를 센다"
|
||
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` 이 안 되어도 놀랄 일이 아니다.
|
||
|
||
**왜 필요한가** — 다음 실험에서 `b3-probe` 이름이 이미 있다고 거절당하는 것을 막는다.
|
||
|
||
**문제가 생기면** — 파드가 `Completed` 로 남아 있으면 같은 `delete` 를 다시 친다.
|
||
|
||
| 항목 | 명령 | 돌아왔을 때 |
|
||
|---|---|---|
|
||
| realm | 위 `get realms/...` | `revokeRefreshToken : false` |
|
||
| 파드 | `kubectl -n keycloak-lab get pods -l app=keycloak` | 둘 다 `1/1 Running`, `RESTARTS 0` |
|
||
| 탐침 | `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` 이 있는지 |
|
||
| 다섯 개가 전부 `200` (`&` 는 있는데) | **회전이 안 켜졌다** | 주입 검증을 다시. 사전 확인이 `200/400` 이어야 한다 |
|
||
| 결과 파일이 비어 있다 | **`wait` 이 없다.** 아직 안 끝난 요청을 읽었다 | `wait` 뒤에 `cat` |
|
||
| 출력이 뒤섞여 어느 줄이 어느 요청인지 모른다 | 다섯 개가 같은 터미널에 동시에 쓴다 | 파일로 받고 나중에 읽는다 |
|
||
| 전부 `400 invalid_grant` 인데 메시지가 한 종류 | **옛 `RT` 를 계속 썼다** (자기오염) | 갱신마다 `RT` 를 다시 담는다 |
|
||
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | **Keycloak 이미지에 curl 도 wget 도 없다** | 탐침 파드를 쓴다 |
|
||
| `CS길이=0` | secret 이름이나 키가 틀렸다 | `get secret bff-secrets -o jsonpath='{.data}'` 로 키 이름만 본다 |
|
||
| `{"error":"unauthorized_client"}` | 클라이언트에 direct grant 가 꺼져 있다 | kcadm 으로 `directAccessGrantsEnabled` 확인 |
|
||
| kcadm 이 `401` / 아무 말 없이 실패 | 로그인 세션이 만료됐다 | `config credentials` 를 다시 |
|
||
| `sid` 가 빈 줄 | base64 패딩 또는 base64url 문자 | `tr '_-' '/+'` 를 넣어 다시 |
|
||
| 데이터베이스 질의에서 행 자체가 없다 | 다른 `SID` 를 넣었다 | 파드 안에서 `echo "$SID"` 를 다시 본다 |
|
||
| 파드 셸에 다시 들어갔더니 변수가 없다 | 그 `exec` 세션이 끝나면 셸 변수는 사라진다 | 셸을 붙잡고 있는다. 터미널 두 개 |
|
||
| 회전을 켠 뒤 브라우저 로그인이 이상하다 | **realm 전체에 걸린 설정이다.** BFF 도 영향받는다 | 실험이 끝나면 반드시 realm 을 되돌린다 |
|
||
|
||
부하 도구가 없는 것도 설계다. 동시성 5는 `ab` 도 `k6` 도 필요 없고 셸의 `&` 와 `wait` 이면 충분하며, 그 편이 무엇이 일어났는지 더 잘 보인다 — 요청 다섯 개의 본문을 전부 파일로 갖고 있으니 나중에 다시 읽는다. 부하 도구는 개수를 늘려야 할 때 쓴다. 이 절차가 묻는 것은 개수가 아니라 겹치면 무엇이 부서지는가이고, 그건 둘만 겹쳐도 답이 나온다. 다섯 개를 쓴 것은 오류 메시지 두 종류가 한 화면에 같이 보이기 때문이지 다섯이 필요해서가 아니다.
|
||
|
||
## 무엇이 관측이고 무엇이 아닌가
|
||
|
||
이 절차의 숫자는 `2026-09-04 14:16–14:17 KST` 에 돈 한 번의 실행에서 나왔다(observed).
|
||
|
||
- (observed) 주입 전 realm 의 세 값 `{ "revokeRefreshToken" : false, "refreshTokenMaxReuse" : 0, "accessTokenLifespan" : 60 }`, 토큰 길이 `811` 과 jti `8e7e3ee2-0dc8-573d-58ec-d12651a50b9c` 와 sid `BvFiB01Rntz1FcLdf7zG4BNt`, 동시 다섯 요청의 상태 코드와 오류 문구 두 종류, 이긴 토큰의 길이 `810` 과 그 토큰으로 다시 갱신했을 때의 `HTTP 400`, 그 sid 의 `offline_flag 0` · `last_session_refresh 1788498996` · `client_sessions 0`, 대조군 세션 `JT-XuepgutWcE273QwAnIXta` 의 `client_sessions 1`, `revoked_count 0`, 구성 B·C 의 sid 와 다섯 코드와 `남은 client_session` 값, `CS길이=15`.
|
||
- (unknown) `&` 와 `wait` 으로 다섯을 동시에 띄우는 while 루프, sid 를 base64url 로 다시 푸는 줄, 순차 다섯 번 갱신 루프, 회전이 도는지 보는 사전 확인 두 줄. 가이드가 전부 미검증으로 표시했고 원래 실행은 스크립트로 했다. 순차 실행은 증거 파일에 기록 자체가 없다.
|
||
- 이 실험이 재지 않은 것 — BFF 를 거쳐 같은 경쟁이 나는지는 재지 않았다. 여기서는 Keycloak 쪽 동작만 갈라 보려고 토큰 엔드포인트를 직접 쳤다. RP-initiated logout 으로 파괴된 세션을 치우는 것도, 동시성을 5보다 늘리면 어떻게 되는지도 재지 않았다.
|
||
- 추론이지 측정이 아닌 것 — `refreshTokenMaxReuse ≥ N-1` 이어야 한다는 것은 A·C 두 구성에서 관측한 결과에서 따라 나온 것이고, N 을 바꿔 가며 재 보지는 않았다. 잠금 후보 셋도 어느 것을 넣어 재현이 사라지는지 재지 않았다 — 이 실험은 무엇이 부서지는가까지다.
|
||
|
||
<!-- body:end -->
|