Files
document-haness/docs/keycloak-session-store/tech-log-studio/where-application-state-lives/setup/setup-reproduce-b6-key-rotation.md
T
DongHyeonkaandClaude Opus 5 024362d096 fix(setup): 실험대를 새로 세워 setup 35편을 밟고 어긋난 명령과 결과를 고친다
기반 가이드 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>
2026-09-17 15:59:42 +09:00

641 lines
39 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.
---
id: 7447ccbf-1800-43a4-a9d2-8ac774965c4b
kind: SETUP
slug: reproduce-b6-key-rotation
title: 서명 키를 더한 뒤 옛 키를 지우고 옛 토큰이 언제 끊기는지 본다
topic: where-application-state-lives
topicName: 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기
project: keycloak-session-store
status: 게시 전
studio: "https://hyeonworks.com/studio/documents/7447ccbf-1800-43a4-a9d2-8ac774965c4b/edit"
pinnedVersions:
- name: curl
version: 8.5.0
source:
- final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-6
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
---
# 서명 키를 더한 뒤 옛 키를 지우고 옛 토큰이 언제 끊기는지 본다
realm 에 RSA 서명 키를 하나 더해 겹치는 구간을 만들고, 옛 공급자를 지운 뒤 두 토큰을 같은 두 줄로 다시 치는 절차다. 지운 키는 개인키와 함께 사라져 돌아오지 않으므로 실험대 전용 realm 에서만 한다. 약 15분.
## 관계
- **볼륨 없는 영속화와 유예 없는 키 회전**
이 절차가 만드는 `옛 401 · 새 200` 을 그 기록이 결론으로 적는다. 결론이 필요하면 그쪽을 읽는다.
- **주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다**
여기서 `-q` 필터가 오류도 종료코드도 없이 빈 결과를 주는 대목이 그 아홉 건 중 하나다.
- **예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다**
이 절차는 주입 검증에서 `old 200` 을 보기 전에는 관찰로 넘어가지 않는다. 그것을 안 보고 지우면 뒤에 나온 401 의 원인을 못 가른다.
- **cookie secret 을 갈아치우고 로그인해 있던 세션이 어떻게 되는지 본다**
같은 회전을 식별자가 없는 쪽에서 치는 편이다. 여기서 `kid` 가 겹침을 가능하게 하는 것을 보고 나면 그쪽에서 겹침이 왜 불가능한지가 한 줄로 끝난다.
## 본문
<!-- body:start -->
## 읽기 전에 — 어디서 치는가
명령은 전부 `[lab host]` 에서 친다. Keycloak 이미지에는 `curl``wget` 도 없어서(`exit 127`) 파드 안에서 HTTP 요청을 보낼 수 없다. JWKS(JSON Web Key Set, 서버가 공개키를 싣는 목록)와 토큰은 호스트에서 공개 이름으로 치고, `kcadm.sh``kubectl exec` 로 감싸 파드 안에서 돌린다.
**원 가이드는 이 명령들을 `kc-lab-1` 에서 치라고 적었다.** 기반 가이드가 세운 실험대에서는 그 기계에 kubeconfig 가 없어서 `sudo` 없는 `kubectl``permission denied` 로 막힌다 — kubeconfig 는 lab host 의 `~/.kube/config` 에만 있다(2026-09-17 에 양쪽에서 쳐서 확인했다, observed). 그래서 `kubectl` 블록의 기계 이름을 `[lab host]` 로 적었고, 노드 자체를 건드리는 명령에만 게스트 셸을 쓴다.
터미널은 하나면 된다. 붙잡아 두어야 하는 셸이 없고, 대신 `OLD``NEW` 두 변수를 끝까지 들고 가므로 중간에 터미널을 닫지 않는다.
| 무엇 | 값 |
|---|---|
| 네임스페이스 | `keycloak-lab` · 리소스 서버는 `header-lab` |
| realm | `keycloak-patterns` — 클라이언트 `bff-confidential`, 사용자 `labuser` |
| 주입 수단 | `kcadm.sh create components``priority` 가 더 높은 RSA 공급자를 하나 더 만든다 |
| 판정하는 쪽 | 리소스 서버 `echo`. 이 절차의 401 과 200 은 전부 그 앱이 낸다 |
| 시간 제약 | access token 수명 60초. 토큰을 받고 1분 안에 그 토큰으로 친다 |
| 전 구간 | 약 15분. 주입 검증까지는 아무것도 안 깨진다 |
| 도구 | `jq` 가 이 실험대에 없다. JSON 은 `tr``grep` 으로 자른다 |
## 이 실험이 가르는 것
암호화 키를 어디에 두고 어떻게 교체하며, 교체하는 동안 옛 키로 저장된 값을 어떻게 읽는가. B층이 들고 온 이 물음이 두 갈래로 갈린다.
| 어느 키인가 | 지금 상태 |
|---|---|
| 토큰 **저장소**의 암호화 키 | 존재하지 않는다. B-2 에서 `bytea` 안이 JWT 문자열 그대로였다 |
| 토큰 **서명** 키 (Keycloak realm) | 존재하고 회전할 수 있다 — 이 절차가 잰다 |
앞의 것이 없으므로 교체할 것도 없다. 그래서 이 절차는 뒤의 것만 치고, 거기서 본 모양이 나중에 앞의 것을 설계할 때 쓰인다.
원래 실행은 예측이 빗나간 실험이었다. 리소스 서버가 JWKS 를 캐시하니 옛 키를 지워도 한동안은 통할 것이라고 적어 두었는데, 제거 직후 바로 401 이 나왔다. 「교체」라는 한 단어가 성질이 정반대인 두 조작을 가리킨다.
```text
키 추가 → 무중단. JWKS 에 옛 키와 새 키가 함께 남는다
키 제거 → ★ 즉시 파괴적. 옛 키로 서명된 토큰이 곧바로 401
```
절차를 끝까지 밟으면 RS256 키 수가 `1 → 2 → 1` 로 움직이는 것, 겹치는 구간에서 옛 토큰과 새 토큰이 둘 다 200 인 것, 제거 뒤에 옛 토큰만 401 이 되는 것, 리소스 서버를 재시작해 캐시를 비워도 그 401 이 그대로인 것을 자기 화면에서 보게 된다.
## 전제와 되돌리기
- `05-keycloak` 이 끝나 있고 realm `keycloak-patterns` 에 클라이언트 `bff-confidential` 과 사용자 `labuser` 가 있다.
- B-0 이 끝나 BFF 가 떠 있다.
- 리소스 서버(`echo`, 네임스페이스 `header-lab`)가 떠 있다. 이 절차의 401 과 200 은 전부 그 앱이 판정한다.
- **`echo` 가 발행자에 닿아야 한다.** 그 앱은 `SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI``…JWK_SET_URI`**`https://auth.hyeonworks.com/…`** 로 들고 있어서, 인증서 단계를 안 끝낸 실험대에서는 JWKS 를 못 받아 **모든 토큰을 `401` 로 떨어뜨린다.** 2026-09-17 에 그 상태에서 재 보니 이랬다(observed).
```text
echo 가 발행자에 닿나 issuer=000 (curl exit 7)
유효한 토큰으로 /api/echo 401
토큰 없이 /api/echo 200
```
`401``200` 이 뒤집혀 보이지만 키 회전과는 무관하다 — 검증기가 공개키를 못 구해 전부 거절했고, 보호되지 않은 경로만 통과했다. **이 편의 판정은 TLS 가 서 있어야 성립한다.**
**이건 되돌릴 수 없는 실험이다.** 지우는 것은 서명 키 공급자이고 그 안의 개인키가 함께 사라진다. 같은 이름으로 공급자를 다시 만들어도 새 키 쌍이 생기고 `kid` 가 달라지므로, 옛 키로 서명된 토큰은 영구히 검증되지 않는다. 실험대에서만 한다.
되돌릴 수 있는 것은 주입 하나다. 방금 만든 공급자를 지우면 원래대로 돌아간다. id 는 주입이 화면에 찍어 주는 값이다 — 원래 실행에서는 `7902af43-a0cc-4ebd-ad25-04d563854d16` 이었다. 아래 블록의 `{{NEW_PROVIDER_ID}}` 를 8 절 출력이 찍어 준 자기 id 로 바꿔야 지워진다. 8 절을 치기 전에는 지울 공급자가 없다.
```bash label="[lab host] 관찰 절로 넘어가기 전에 그만둘 때 — id 를 8 절 출력의 자기 값으로 바꾼다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
delete components/{{NEW_PROVIDER_ID}} -r keycloak-patterns
```
## 주입 전에 같은 명령으로 먼저 본다
제거 후에 볼 것을 제거 전에 똑같은 명령으로 먼저 봐 둔다. 넓은 것부터 좁혀 간다.
```text
kcadm 로그인 → 키 공급자 목록 → JWKS 원문 → 토큰의 kid → 그 토큰이 통하는가
```
### 1. kcadm 세션을 파드 안에 만든다
**목적** — 뒤의 모든 `kcadm.sh` 명령이 관리 API 로 인증되게 한다.
**행동** — 관리자 자격증명으로 로그인하고, 값이 넘어갔는지는 길이로만 본다.
```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)"
```
```bash label="[lab host] ② 비밀번호의 길이만 센다"
kubectl -n keycloak-lab get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d | wc -c
```
**예상 결과** — ① 은 아무것도 안 나오면 성공이다. 실패하면 한 줄짜리 오류가 뜬다. ② 는 0 이 아닌 수를 낸다.
**왜 필요한가** — `kcadm.sh` 는 파드 안 파일에 세션을 저장한다. 파드가 재시작되면 그 파일이 사라지고 그다음 모든 명령이 `401` 로 떨어지므로 맨 앞에서 한 번 해 둔다. 비밀번호는 명령 치환으로 넘기므로 값이 터미널에도 셸 히스토리에도 남지 않는다.
**문제가 생기면** — 중간에 `kcadm` 이 전부 `Unauthorized` 로 바뀌면 파드가 재시작된 것이다. ① 을 다시 친다.
### 2. 키 공급자 목록을 통째로 받는다
**무엇을 보는가** — 이 realm 에 어떤 키 공급자가 있고 각각의 id 가 무엇인지.
```bash label="[lab host] 공급자 목록을 필드 셋으로 받는다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get components -r keycloak-patterns --fields id,name,providerId
```
**어디를 보나** — JSON 배열이 여러 줄로 나온다. `providerId` 가 `rsa-generated` 인 항목이 서명 키 공급자이고 `hmac-generated` · `aes-generated` 등이 함께 나온다. `"name" : "rsa-generated"` 인 항목의 `"id"` 를 지금 적어 둔다. 관찰 절에서 지울 대상이다.
**이 값이 뜻하는 것** — 가이드는 「키 공급자만 걸러 보자」는 아래 시도가 빈 결과를 준다고 적고 미검증으로 표시했다.
```bash label="[lab host] 키 공급자만 걸러 본다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get components -r keycloak-patterns -q type=org.keycloak.keys.KeyProvider
```
**2026-09-17 에 쳐 보니 결과가 나왔다**(observed). 26.7.0 에서 이 질의는 공급자 넷을 그대로 돌려준다.
```text
"name":"rsa-enc-generated" "name":"hmac-generated-hs512" "name":"aes-generated" "name":"rsa-generated"
```
**빈 결과를 봤다면 세션이 없어서일 수 있다.** 같은 날 세션이 끊긴 상태로 먼저 쳤을 때는 이렇게 끝났다(observed).
```text
No server specified. Use --server, or 'kcadm.sh config credentials'.
```
어느 쪽이든 판정은 같다 — **빈 출력은 「없다」가 아니라 「이 명령으로는 안 보인다」일 수 있다.** 그때는 `--fields` 로 전체를 받아 눈으로 고른다.
:::warning
**`kcadm` 세션은 파드 안에 산다.** `/opt/keycloak/.keycloak/kcadm.config` 에 놓이므로 `rollout restart` 나 이미지 교체로 파드가 갈리면 **그 파일째 사라진다.** 그러면 `401` 이 아니라 `No server specified` 로 끝나고, 앞 절에서 `config credentials` 를 이미 쳤어도 소용없다. 파드를 갈아 끼운 뒤에는 다시 친다(2026-09-17, observed).
:::
### 3. JWKS 원문을 한 번 통째로 본다
**무엇을 보는가** — 어떤 필드가 실려 있는지. 다음부터 무엇으로 걸를지가 여기서 정해진다.
```bash label="[lab host] ① JWKS 를 자르지 않고 본다"
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs
```
줄바꿈 없이 한 줄로 길게 나온다. 실측의 첫머리는 이렇다(observed, `01-before-rotation.txt`).
```text
{"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
```
그 뒤로 `kty` · `alg` · `use` · `n` · `e` 가 이어지고 다음 키가 온다. `kid` 마다 `alg` 가 따로 붙는다. 읽을 만하게 자를 때는 `jq` 가 없으므로 `tr` 로 쉼표를 줄바꿈으로 바꾼다.
```bash label="[lab host] ② kid 만 뽑아 본다"
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
| tr ',' '\n' | grep kid
```
**어디를 보나** — 실측은 이렇다(observed, `01-before-rotation.txt`).
```text
JWKS kid 목록:
{"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
{"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"
```
**이 값이 뜻하는 것** — `kid` 는 둘인데 같은 파일의 윗줄은 RS256 키가 하나라고 적는다(observed).
```text
JWKS 의 RS256 키 수: 1
```
세는 단위가 다르다. JWKS 에는 서명 키만 실리지 않는다. 이 realm 에서는 암호화용 키(`RSA-OAEP` 계열)가 함께 실려 있고 그것도 `kid` 를 갖는다. `grep kid | wc -l` 로 세면 서명 키 수를 과다 계산한다.
### 4. RS256 만 세는 두 형태를 알아 둔다
**무엇을 보는가** — 알고리즘까지 보고 세는 방법. 아래 두 줄은 가이드가 미검증으로 표시했다(unknown).
JWKS 는 키 하나가 `}` 로 끝나므로 `tr '}'` 로 자르면 한 줄이 한 키가 된다.
```bash label="[lab host] ① 키 단위로 잘라 RS256 만 센다 (unknown)"
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
| tr '}' '\n' | grep -c RS256
```
Keycloak 자신에게 묻는 쪽이 확실하고 그쪽이 1순위 도구다.
```bash label="[lab host] ② Keycloak 에 직접 묻는다 (unknown)"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get keys -r keycloak-patterns
```
**어디를 보나** — 키마다 붙는 `algorithm` 과 `status` 를 보고, `RS256` 이면서 `ACTIVE` 인 것이 지금 서명에 쓰이는 키다.
**이 값이 뜻하는 것** — 이 두 명령은 원래 실행 기록에 출력이 없다. 위에 인용한 「RS256 키 수: 1」만이 실측이다.
### 5. 시험체가 될 옛 토큰을 하나 받아 둔다
**목적** — 회전 전에 발급된 토큰을 확보한다. 이 토큰 하나가 이 절차의 시험체다.
**행동** — 토큰 엔드포인트와 클라이언트 비밀을 변수에 담고 direct grant 로 받는다.
```bash label="[lab host] ① 옛 키로 서명된 토큰을 받고 길이만 본다"
KC=https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/token
CS=$(kubectl -n keycloak-lab get secret bff-secrets \
-o jsonpath='{.data.KEYCLOAK_CLIENT_SECRET}' | base64 -d)
OLD=$(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 \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
echo "${#OLD}자"
```
**예상 결과** — 길이만 나온다. 원래 실행의 모양은 이렇다(observed).
```text
2043자
```
**왜 필요한가** — 변수 이름이 `OLD` 인 까닭은 회전이 끝난 뒤에도 이 값이 「옛 키로 서명된 토큰」으로 남아 있어야 하기 때문이다. 중간에 다시 받으면 새 키로 서명되어 실험이 성립하지 않는다. 토큰 값도 클라이언트 비밀도 화면에 찍지 않고 길이만 본다.
**문제가 생기면** — `0자` 가 나오면 토큰을 못 받았다. 변수에 담지 말고 같은 `curl` 을 그대로 쳐서 응답 본문을 읽는다.
### 6. 그 토큰이 어느 키로 서명됐는지 읽는다
**무엇을 보는가** — JWT 의 첫 토막이 헤더이고 거기 `kid` 가 있다.
```bash label="[lab host] 토큰 헤더를 디코드한다"
echo "$OLD" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
```
**어디를 보나** — 원래 실행의 모양은 이렇다(observed).
```json
{"alg":"RS256","typ" : "JWT","kid" : "OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"}
```
**콜론 양옆의 공백을 눈여겨본다.** Keycloak 은 토큰 **헤더**를 `"typ" : "JWT"` 처럼 공백을 넣어 찍고, 페이로드는 `"sid":"…"` 처럼 붙여 찍는다. 그래서 페이로드에서 되던 `sed` 가 헤더에서는 빈손으로 돌아온다. 2026-09-17 에 같은 토큰 하나로 두 형태를 나란히 쳤다(observed).
```text
헤더: {"alg":"RS256","typ" : "JWT","kid" : "HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg"}
'"kid":"' 로 뽑으면 : []
'"kid" *: *"' 로 뽑으면: [HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg]
```
헤더에서 값을 뽑을 때는 공백을 허용한다.
```bash label="[lab host] 헤더에서 kid 만 뽑는다 — 콜론 양옆 공백을 허용한다"
echo "$OLD" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null \
| sed -n 's/.*"kid" *: *"\([^"]*\)".*/\1/p'
```
증거 파일에는 이렇게 남아 있다(observed, `01-before-rotation.txt`).
```text
발급 토큰의 kid: OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM
```
**이 값이 뜻하는 것** — `kid` 는 key ID 이고, 서명한 쪽이 어느 키를 썼는지 토큰 헤더에 적어 준다. 검증하는 쪽은 JWKS 에서 그 `kid` 를 찾아 공개키를 얻는다. `kid` 가 없다면 검증자는 「지금 유효한 키」 하나만 알 수 있고, 키가 바뀌는 순간 옛 토큰이 전부 죽는다. 겹치는 구간을 가능하게 하는 것이 이 `kid` 다. 여기서 본 값이 3 절의 목록에 있는지 대조한다. base64 패딩 때문에 끝이 깨져 보일 수 있고(`2>/dev/null` 이 그 불평을 지운다) 헤더는 짧아서 대개 온전히 보인다.
### 7. 그 토큰이 지금 통하는지 본다
**무엇을 보는가** — 대조군. 이 확인을 건너뛰면 뒤의 401 이 아무 의미가 없다.
```bash label="[lab host] ① 상태줄과 본문을 함께 본다"
curl -s -i -H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
```
200 이면 `subject` 같은 클레임이 돌아오고, 401 이면 `WWW-Authenticate` 헤더에 이유가 붙는다. 이 헤더를 한 번 봐 두면 뒤에서 401 이 났을 때 왜인지 물을 근거가 생긴다. 여러 번 비교할 때부터는 코드만 뽑는다.
```bash label="[lab host] ② 상태 코드만 뽑는다"
curl -s -o /dev/null -w 'old %{http_code}\n' \
-H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
```
**어디를 보나** — 실측은 이렇다(observed, `01-before-rotation.txt`).
```text
=== [2] 그 토큰이 지금 통하는가 (리소스 서버) ===
/api/me HTTP 200
```
**이 값이 뜻하는 것** — 회전 전에는 통한다. 원래 실행은 클러스터 안에서 `http://echo.header-lab.svc:8081/api/me` 를 쳤고, 이 절차가 공개 이름을 쓰는 까닭은 `kc-lab-1` 에서 클러스터 DNS 가 안 풀리기 때문이다. `app1.hyeonworks.com` 의 `/api` 는 Ingress 가 같은 `echo` 로 보내므로 도달하는 앱은 같다. 그리고 access token 은 60초짜리다(이 realm 은 `accessTokenLifespan=60`). 1분을 넘기면 회전과 무관하게 401 이 나온다.
## 주입
### 8. 우선순위가 더 높은 RSA 공급자를 추가한다
**목적** — 발급은 새 키로 가고 검증은 옛 키와 새 키를 둘 다 받는 상태를 만든다.
Keycloak 의 키 회전은 바꾸기가 아니라 더 높은 우선순위로 추가하기다. 기존 공급자는 그대로 두고 `priority` 가 더 큰 공급자를 하나 더 만든다.
```text
t0 키 A 만 있다. 발급: A, 검증: A
t1 키 B 추가. 발급: B, 검증: A + B ← 겹치는 구간
t2 키 A 제거. 발급: B, 검증: B
```
**행동** — 공급자를 만들고 시각을 남긴다.
```bash label="[lab host] priority 200 짜리 RSA 공급자를 만든다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
create components -r keycloak-patterns \
-s name=rsa-rotated -s providerId=rsa-generated \
-s providerType=org.keycloak.keys.KeyProvider \
-s 'config.priority=["200"]' -s 'config.algorithm=["RS256"]' -s 'config.keySize=["2048"]'
date '+%H:%M:%S 추가'
```
**예상 결과** — 실측은 이렇다(observed, `02-rotation.txt`).
```text
=== [3] 키 회전 — 우선순위가 더 높은 RSA 공급자를 추가한다 ===
Created new component with id '7902af43-a0cc-4ebd-ad25-04d563854d16'
```
돌아온 id 를 적어 둔다. 전제 절의 되돌리기가 그 값을 쓴다.
**왜 필요한가** — `config.priority` 가 기존 공급자보다 커야 발급이 새 키로 간다. 기본값은 100 이고 여기서는 200 을 줬다. 낮게 주면 새 키는 만들어지지만 발급에 쓰이지 않아 주입 검증에서 `kid` 가 안 바뀐다. `config.*` 값이 대괄호로 감싼 배열인 것에도 주의한다 — `-s config.priority=200` 처럼 쓰면 형이 안 맞는다. Keycloak 컴포넌트 설정은 값이 전부 문자열 목록이다.
**문제가 생기면** — 생성이 거절되면 `config.*` 의 대괄호부터 본다.
## 주입 검증
결과를 해석하기 전에, 주입이 의도한 것만 건드렸는지 본다. 주입 전과 똑같은 명령을 다시 친다.
### 9. JWKS 에 옛 키가 남아 있는가
```bash label="[lab host] 3 절과 똑같은 줄을 다시 친다"
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
| tr ',' '\n' | grep kid
```
실측은 이렇다(observed, `02-rotation.txt`).
```text
=== [4] 회전 후 JWKS — 옛 키가 남아 있는가 ===
RS256 키 수: 2
kid 목록:
{"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
{"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"
```
옛 `kid`(`OY-caYDN…`)가 목록에서 빠지지 않았다. 새 것이 하나 늘었고 아무것도 사라지지 않았다. JWKS 는 지금 검증에 쓸 수 있는 키 전부를 싣는 목록이고, 추가는 그 목록을 늘린다.
### 10. 새 토큰은 어느 키로 서명되는가
`OLD` 은 건드리지 않는다.
```bash label="[lab host] 새 토큰을 받고 헤더를 읽는다"
NEW=$(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 \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')
echo "$NEW" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
```
실측은 이렇다(observed, `02-rotation.txt`).
```text
=== [5] 새 토큰은 어느 키로 서명되는가 ===
새 토큰의 kid: 1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84
```
`kid` 가 우선순위 200 짜리 새 키로 바뀌었다. 발급은 우선순위가 가장 높은 키로 간다. 여기서 `kid` 가 안 바뀌었다면 `priority` 를 낮게 준 것이다.
### 11. 둘 다 통해야 겹치는 구간이 무중단이다
```bash label="[lab host] 두 토큰을 같은 두 줄로 친다"
curl -s -o /dev/null -w 'old %{http_code}\n' \
-H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
curl -s -o /dev/null -w 'new %{http_code}\n' \
-H "Authorization: Bearer $NEW" https://app1.hyeonworks.com/api/me
```
실측은 이렇다(observed, `02-rotation.txt`).
```text
=== [6] ★ 회전 전에 발급된 토큰은 아직 통하는가 ===
옛 토큰 /api/me HTTP 200
새 토큰 /api/me HTTP 200
```
둘 다 200 이므로 키 추가는 무중단이다. 새 토큰은 새 키로 서명되고 옛 토큰은 JWKS 에 아직 있는 옛 키로 검증되며 사용자는 아무것도 못 느낀다.
여기서 `old` 가 401 이면 둘 중 하나다. 토큰이 만료됐거나(60초), 추가 말고 다른 것을 건드렸다. 가르는 법은 옛 토큰의 `exp` 를 보는 것이고 JWT 의 가운데 토막이 클레임이다.
```bash label="[lab host] 만료인지 아닌지 가른다"
echo "$OLD" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
date +%s
```
`exp` 가 지금보다 작으면 만료다. 다시 받아야 하는데, 순서 그대로 5 절만 다시 치면 8 절이 만든 `rsa-rotated` 가 이미 `priority` 200 이라 새로 받은 토큰도 새 키로 서명된다. 8 절이 화면에 찍어 준 id 로 그 공급자부터 지우고(전제 절의 되돌리기 한 줄), 5 절로 토큰을 받고, 8 절을 다시 쳐서 추가한 뒤 11 절로 온다.
## 관찰
**여기서부터 되돌릴 수 없다.** 계속하기 전에 셋을 확인한다 — 이 realm 이 실험대 전용인가, 지금 살아 있는 세션 중에 잃으면 곤란한 것이 있는가, 11 절의 `old 200` 을 실제로 봤는가. 마지막 것을 안 봤다면 401 이 나와도 원인을 못 가른다.
**넷째로 `$OLD` 에 시간이 얼마나 남았는지 본다.** access token 이 60초짜리라, 12 절에서 목록을 받아 눈으로 id 를 고르고 13 절에서 그 id 를 손으로 옮겨 적는 동안 만료된다. 그러면 15 절의 `old 401` 이 키 제거 때문인지 만료 때문인지 안 갈린다 — 이 절차의 결론이 바로 그 401 이라, 여기서 못 가르면 아무것도 못 잰다. 11 절의 만료 확인 두 줄을 지금 한 번 쳐서 `exp` 와 `date +%s` 의 차이를 보고, 12·13 절을 칠 만큼 안 남았으면 11 절이 안내한 대로 `$OLD` 를 다시 받고 온다.
### 12. 지울 대상을 정확히 고른다
**무엇을 보는가** — 남길 것과 지울 것의 id. `-q` 는 여전히 안 먹는다.
```bash label="[lab host] ① 목록을 다시 받는다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get components -r keycloak-patterns --fields id,name,providerId
```
목록이 길면 그 항목 둘레만 잘라 본다. `"id"` 는 `"name"` 보다 위에 나온다.
```bash label="[lab host] ② 지울 항목 둘레만 본다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get components -r keycloak-patterns --fields id,name,providerId \
| grep -B2 '"name" : "rsa-generated"'
```
**어디를 보나** — 남길 것이 `"name" : "rsa-rotated"` 이고 지울 것이 `"name" : "rsa-generated"` 다.
**이 값이 뜻하는 것** — 둘을 바꿔 지우면 실험이 뒤집힌다. 원래 실행에서 옛 공급자의 id 는 `980ee9b7…` 로 시작했고 그것이 `OY-caYDN…` 키를 갖고 있었다. 환경마다 id 가 다르고 증거에 남은 것도 앞 8자뿐이니 전체 id 는 위 출력에서 그대로 옮겨 온다.
### 13. 옛 공급자를 지운다
**목적** — 옛 서명 키를 JWKS 에서 없앤다.
**행동** — 위 출력의 id 를 변수에 옮기고 지운다. 첫 줄의 `{{OLD_PROVIDER_ID}}` 를 12 절 출력에서 읽은 자기 id 로 바꾼 뒤에 친다. **그대로 붙여넣으면 셸이 멈춘다** — `{{ }}` 는 셸 문법이 아니라서 눈앞에서 실패한다. 전에는 첫 줄에 원래 실행의 값이 그대로 박혀 있었는데, 그것은 **유효한 대입이라 조용히 돌았다**: `OLDID` 에 그 문자열이 들어가고 아래 `delete` 가 실제로 나가 없는 컴포넌트를 지우라는 요청이 되고, 옛 공급자는 살아 있고, 15 절이 `옛 200` 을 내 결론이 뒤집힌다.
```bash label="[lab host] 옛 공급자를 지우고 시각을 남긴다"
OLDID={{OLD_PROVIDER_ID}} # ← 12 절 출력의 id 를 그대로 옮긴다. 환경마다 다르다
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
delete components/"$OLDID" -r keycloak-patterns
date '+%H:%M:%S 제거'
```
**예상 결과** — 조용히 끝나면 성공이다. 증거 파일에는 이렇게 남아 있다(observed, `03-old-key-removed.txt`).
```text
=== [7] 옛 RSA 공급자(980ee9b7 = OY-caYDN 키) 제거 ===
제거 완료
```
**왜 필요한가** — 시각을 적어 두면 뒤에 나온 401 을 이 조작에 귀속할 수 있다. 그리고 지워진 것은 공급자이므로 그 안의 개인키도 함께 사라진다.
**문제가 생기면** — 지운 뒤 새 토큰까지 401 이면 새 공급자를 지운 것이다. 14 절과 15 절을 먼저 치고 `kid` 를 대조한다.
### 14. JWKS 에서 사라졌는지 본다
```bash label="[lab host] 또 같은 줄을 친다"
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
| tr ',' '\n' | grep kid
```
실측은 이렇다(observed, `03-old-key-removed.txt`).
```text
=== [8] JWKS 에서 사라졌는가 ===
RS256 키 수: 1
{"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
```
`OY-caYDN…` 이 없고 RS256 은 다시 하나다. `gokjn0…` 은 처음부터 끝까지 목록에 있는데, 서명 키가 아니라 암호화 키이기 때문이다.
### 15. 두 토큰을 같은 두 줄로 다시 친다
**치기 전에 `$OLD` 가 아직 안 죽었는지 본다.** 11 절의 만료 확인 두 줄을 그대로 다시 친다. `exp` 가 `date +%s` 보다 작으면 아래에서 나올 `old 401` 은 키 제거가 아니라 만료이고, 두 401 은 화면에서 똑같이 보인다.
```bash label="[lab host] 11 절과 똑같은 두 줄"
curl -s -o /dev/null -w 'old %{http_code}\n' \
-H "Authorization: Bearer $OLD" https://app1.hyeonworks.com/api/me
curl -s -o /dev/null -w 'new %{http_code}\n' \
-H "Authorization: Bearer $NEW" https://app1.hyeonworks.com/api/me
```
실측은 이렇다(observed, `03-old-key-removed.txt`).
```text
=== [9] ★ 옛 키로 서명된 토큰은 이제 어떻게 되는가 ===
옛 토큰 /api/me HTTP 401 (캐시가 살아 있으면 아직 통할 수 있다)
새 토큰 /api/me HTTP 200
```
제거는 즉시 반영된다. 괄호 안의 「캐시가 살아 있으면 아직 통할 수 있다」는 측정하기 전에 적어 둔 예상이고, 옆의 401 이 그 예상을 부정한 값이다. 증거 파일에 예상과 결과가 나란히 남아 있다.
**`$OLD` 가 만료된 뒤였다면 이 실행은 15·16 절의 판정을 못 낸다.** 옛 키는 13 절에서 개인키와 함께 사라져 옛 키로 서명된 토큰을 새로 만들 방법이 없다. 그때는 401 을 키 제거에 귀속하지 말고, 실험대를 5 절부터 다시 밟되 8 절에서 15 절까지를 토큰 수명 안에 끝낸다. 원래 실행이 그 구간을 얼마 만에 끝냈는지는 원본 가이드에 없다(unknown).
### 16. 리소스 서버를 재시작해 캐시를 비운다
**목적** — 401 이 캐시 상태 때문인지 가른다.
```bash label="[lab host] echo 를 다시 띄우고 기다린다"
kubectl -n header-lab rollout restart deploy/echo
kubectl -n header-lab rollout status deploy/echo --timeout=180s
```
그다음 15 절의 두 줄을 다시 친다. 실측은 이렇다(observed, `03-old-key-removed.txt`).
```text
=== [10] 리소스 서버를 재시작해 JWKS 캐시를 비우면 ===
deployment "echo" successfully rolled out
옛 토큰 /api/me HTTP 401
새 토큰 /api/me HTTP 200
```
**왜 필요한가** — 재시작 전과 후가 같으므로 401 은 캐시 상태와 무관하고 캐시는 유예를 주지 않았다. Spring 의 `NimbusJwtDecoder` 는 모르는 `kid` 를 만나면 JWKS 를 다시 가져온다. 캐시는 이미 아는 키를 다시 안 받으려는 장치이지 옛 키를 붙잡아 두는 장치가 아니다.
```text
옛 토큰 도착
├─▶ kid = OY-caYDN… → 캐시에 없다
│ │
│ └─▶ JWKS 를 다시 가져온다 (여기서 오히려 빨리 갱신된다)
└─▶ 새로 받은 JWKS 에도 없다 → 401
```
캐시가 오히려 제거를 빨리 반영시킨다. 유예는 캐시로 만드는 것이 아니라 옛 키를 JWKS 에 남겨 두는 기간으로 만든다.
**문제가 생기면** — 재시작 뒤 옛 토큰이 200 이면 지운 것이 그 토큰의 키가 아니다. 12 절의 목록과 6 절의 `kid` 를 대조한다.
### 겹치는 구간은 얼마나 길어야 하나
**이 절차는 그 길이를 재지 않았다.** 추가와 제거가 연달아 일어났고 전 구간이 약 15분이다. 아래 값은 realm 설정에서 따라 나온 추론이다.
| 이 실험대에서 | 수명 |
|---|---|
| access token | 60초 |
| refresh token | 1800초 (30분) |
| 필요한 겹침 | 최소 30분 — 앞 두 값에서 따라 나온 추론이고 측정하지 않았다 |
겹침의 최소 길이는 옛 키로 서명된 것 중 가장 오래 사는 것의 수명과 같다. 실제로 재려면 추가와 제거 사이를 30분 이상 벌리고, 그 사이에 받은 refresh token 으로 제거 뒤에 갱신을 시도한다. 이 절차에는 그 단계가 없다.
수명 세 값은 realm 설정이므로 직접 볼 수 있다. 가이드가 이 줄을 미검증으로 표시했다(unknown).
```bash label="[lab host] realm 의 수명 세 값을 받는다 (unknown)"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get realms/keycloak-patterns --fields accessTokenLifespan,ssoSessionIdleTimeout,ssoSessionMaxLifespan
```
겹침 길이를 정하는 것은 키가 아니라 그 키로 만든 것의 수명이다. 30분짜리 refresh token 을 발급하면서 겹침을 5분만 두면 25분어치의 토큰을 죽인다. 토큰 저장소의 암호화 키를 나중에 설계할 때도 같은 모양이 된다.
```text
쓰기: 새 key 하나로만
읽기: 새 key + 옛 key(들) ← key 에도 식별자가 필요하다
제거: 옛 key 로 암호화된 마지막 항목이 만료된 뒤
```
저장된 값에 `kid` 에 해당하는 표시가 없으면 회전이 불가능하다. 그것이 없을 때 무슨 일이 나는지는 B-7 이 잰다.
## 복구와 원상복구 확인표
**이 절차에는 원상복구가 없다.** 지운 키 공급자는 개인키와 함께 사라졌고, 같은 이름으로 다시 만들면 새 키 쌍이 생기고 `kid` 가 다르므로 옛 토큰은 그래도 401 이다. 정상 상태는 새 키 하나만 남은 상태이고, 실험 전과 다르지만 깨진 상태가 아니다.
| 남은 것 | 어떻게 | 왜 |
|---|---|---|
| `rsa-rotated` 공급자 | 그냥 둔다. 지금 유일한 RS256 서명 키다 | 지우면 realm 이 서명할 키를 잃는다 |
| 셸 변수 `OLD` `NEW` `CS` | 터미널을 닫으면 사라진다 | `unset OLD NEW CS` |
| 실험 중 발급한 토큰 | 60초 뒤 만료된다 | 별도 조치 없음 |
이름이 거슬리면 새 공급자를 하나 더 만들고 `rsa-rotated` 를 지운다. 다만 그것 역시 또 한 번의 회전이고 또 하나의 새 키다.
| 항목 | 명령 | 돌아왔을 때 |
|---|---|---|
| 서명 키 | `curl -s …/protocol/openid-connect/certs \| tr ',' '\n' \| grep kid` | RS256 이 하나 |
| 새 토큰 | 토큰 발급 + `/api/me` | `200` |
| 리소스 서버 | `kubectl -n header-lab get pods` | `echo` 가 `1/1 Running` |
| Keycloak | `kubectl -n keycloak-lab get pods` | 둘 다 `1/1 Running` |
| 공급자 목록 | `kcadm get components --fields id,name,providerId` | `rsa-generated` 가 없고 `rsa-rotated` 가 있다 |
## 막히면
원래 실행이 실제로 겪은 증상이고 지어낸 것은 없다고 가이드가 적는다.
| 증상 | 원인 | 확인 |
|---|---|---|
| `kcadm get components -q type=...` 가 빈 결과 | `-q` 필터가 안 먹는다. 오류도 없다 | `--fields id,name,providerId` 로 전체를 받는다 |
| `kcadm` 이 전부 `401`/`Unauthorized` | 파드가 재시작되어 kcadm 세션이 사라졌다 | `config credentials` 를 다시 |
| `kubectl exec keycloak-0 -- curl` 이 `exit 127` | Keycloak 이미지에 curl 도 wget 도 없다 | JWKS 와 토큰은 `kc-lab-1` 호스트에서 친다 |
| `jq: command not found` | 이 실험대에는 jq 가 없다 | `tr ',' '\n' \| grep` 로 자른다 |
| kid 를 세니 2개인데 문서는 1개라고 한다 | RS256 이 아닌 암호화 키가 섞여 있다 | `tr '}' '\n' \| grep -c RS256` 또는 `kcadm get keys` |
| 공급자를 추가했는데 새 토큰의 kid 가 그대로 | `config.priority` 가 기존보다 낮다 | 값이 `["200"]` 처럼 배열인지 |
| 추가만 했는데 옛 토큰이 401 | 추가가 아니라 토큰이 만료됐다(60초) | 클레임의 `exp` 와 `date +%s` 비교 |
| 제거했는데 새 토큰이 401 | 지운 것이 새 공급자다 | `kid` 를 다시 확인하고 남은 공급자 목록을 본다 |
| 제거했는데 옛 토큰이 200 | 지운 것이 그 토큰의 키가 아니다 | 토큰 헤더의 `kid` 와 지운 공급자의 키를 대조 |
| 「캐시 때문일 것」이라 재시작을 기다린다 | 캐시는 유예를 주지 않는다 | 재시작 전후가 같다 |
| 지운 키를 되살리려 한다 | 되살릴 수 없다. 같은 이름과 같은 키는 다르다 | 새 키 하나만 남은 상태가 정상이다 |
## 이 실험대에서 아직 못 밟은 단계
2026-09-17 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.**
- **남은 것** — 키 공급자 추가·삭제와 `echo` 가 내는 `401`/`200` 판정 전부. `echo` 가 JWKS 를 `https://auth.hyeonworks.com` 에서 받으므로 인증서가 서야 한다. 지금까지 밟은 것 — realm·사용자·`echo` 배포, JWKS 와 `kid` 읽기, 공급자 목록.
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)와, 밖에서 실험대에 닿는 길(호스트의 libvirt `guest_input` 구멍 — A-4 에서 확인한 `ExecStartPost` 누락)이 둘 다 있어야 한다.
- **그때까지 이 편의 실측 가운데 `(observed)` 로 적힌 2026-09-17 값은 위 「지금까지 밟은 것」 범위뿐이다.** 나머지는 원래 실행의 값이다.
## 무엇이 관측이고 무엇이 아닌가
이 절차의 숫자는 `2026-09-04 14:3014:32 KST` 에 돈 한 번의 실행에서 나왔다(observed). 해설 문서 머리의 `15:5016:00 KST` 는 문서를 쓴 시각이고 증거 파일의 mtime 이 앞의 값이라, 실측으로 인용하는 것은 뒤쪽이라고 가이드가 적는다.
- (observed) 회전 전 JWKS 의 `kid` 두 개(`gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4` · `OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM`)와 RS256 키 수 1, 발급 토큰의 `kid` 가 `OY-caYDN…` 인 것, 그 토큰의 `/api/me HTTP 200`, 추가한 공급자의 id `7902af43-a0cc-4ebd-ad25-04d563854d16`, 회전 후 RS256 키 수 2 와 늘어난 `kid` `1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84`, 새 토큰의 `kid` 가 그것인 것, 겹치는 구간의 `옛 200 · 새 200`, 제거 후 RS256 키 수 1 과 `옛 401 · 새 200`, `echo` 를 재시작한 뒤에도 `옛 401 · 새 200` 인 것.
- 비밀은 길이와 존재만 적었다. 클라이언트 비밀은 `CS` 변수에 명령 치환으로만 넘겨 화면에 찍지 않고, admin 비밀번호도 `wc -c` 로 길이만 본다. 토큰은 `2043자` 라는 길이만 옮겼고 값은 증거 파일에 있다. `kid` 와 공급자 id 는 공개 식별자라 그대로 적었다.
- (unknown) `-q type=org.keycloak.keys.KeyProvider` 로 거르는 줄(조용히 빈 결과를 준다), `tr '}' '\n' | grep -c RS256` 로 RS256 만 세는 줄, `kcadm get keys` 로 알고리즘과 상태를 보는 줄, realm 의 수명 세 값을 한 번에 받는 줄. 가이드가 전부 미검증으로 표시했고 원래 실행 기록에 이 명령들의 출력이 없다.
- 판 번호는 이 편의 출력에 하나도 안 찍혔다. B층 아홉 편 가운데 판 번호가 남은 것은 다섯 편이고 B-6 은 거기 없다(observed). `curl 8.5.0` 은 같은 실험대의 B-4 가 echo 앱에서 되돌려받은 user-agent 이지 이 편이 잰 값이 아니다(inferred).
- 추론이지 측정이 아닌 것 — 「겹침은 최소 30분」은 access token 60초와 refresh token 1800초라는 설정에서 따라 나온 값이다. 겹침을 실제로 30분 유지하며 그 사이에 발급된 refresh token 이 제거 뒤에 어떻게 되는지는 측정하지 않았다.
<!-- body:end -->