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 32e39e20aa fix(setup): 실험대에서 35편을 끝까지 밟고 어긋난 명령·결과 31건을 고친다
test-server 를 비우고 다시 세운 뒤 Setup 기록 35편(virtualization 9 ·
keycloak-session-store 26)을 문서에 적힌 명령 그대로 쳤다. 어긋난 자리를
기록과 SSOT 양쪽에 실측과 함께 넣었다.

막히던 것
- 04 의 인증서 경로가 live/hyeonworks.com 이라 nginx 가 [emerg] 로 안 떴다.
  실제 계보는 live/auth.hyeonworks.com 이고 「문제가 생기면」은 진단이 거꾸로였다
- 인증서가 와일드카드가 아니다. SAN 이 auth·app1·app2 셋뿐이라 그 밖의 이름은
  TLS 에서 끊기고 curl 이 exit 60 · %{http_code} 000 을 낸다. SSOT 안에서
  두 문단이 서로 어긋나 있었다
- A-7 14번 ①이 kc-lab-1 에서 여섯 줄 다 실패하는데 마지막 date 만 「차단」을 찍는다

검사가 실패할 수 없던 자리
- B-1 의 세션 키 고르기는 앞 단계가 $KEY 를 채워 둬서 루프가 한 건도 못 맞혀도
  통과한다. KEY= 로 비우고 키마다 1/0 을 찍게 바꿨다
- k3s-agent 유닛의 sed -i 는 패턴에 $HOME 이 들어 있어 아무 줄도 안 바꾼 채 성공한다

certbot
- renew --dry-run 의 종료 코드는 성공도 0, 실패도 0, 다른 사유의 실패는 1 이다.
  본문의 renew failure(s) 로만 판정할 수 있다
- --dry-run 은 staging 서버를 쓰는데 renewal/*.conf 의 account= 는 운영 계정을
  가리킨다. 실패한 dry-run 이 staging 계정을 하나 더 만들어 다음 실행이 계속 멎는다
- 훅을 755 로 놓고 시뮬레이션이 성공해도 Running deploy-hook command 는 안 나온다.
  certbot 2.1.0 에는 --run-deploy-hooks 도 없다
- 강제 갱신은 실제로 쳤고 서빙까지 닿았다. serial 06F3E0EF…1373 → 065547…3DF1,
  notAfter Dec 3 → Dec 16, nginx worker 2629 4712 → 4745 4754

독자가 칠 수 있는 형태로
- 안 되는 형태가 번호 붙은 단계에 앉아 있던 8곳을 뒤집고, 되는 형태를 ①로 올렸다
- 랩 안에서 공개 이름을 치는 curl 65줄에 --resolve 를 붙였다. 붙인 형태를 실제로
  쳐서 문서가 적은 값과 같은지 확인했다
- 힙독·sed -i·echo >>·&&·|| 를 편집기 + 파일 리스팅 + 분할 형태로 바꿨다
- 닫는 코드펜스가 빠져 뒤 200여 줄의 블록 종류가 뒤집혀 있던 곳을 포함해 3곳을 고쳤다

관문: check_body PASS · check_prose error 0 · check_evidence 두 프로젝트 문제 없음 ·
verify-tech-log-tree error 0 · verify-project-layout error 0 · 코드펜스 전수 0건

남은 것: B-0 주입은 keycloak-pattern 저장소의 소스를 고치고 이미지를 다시 구워야
해서 안 했다(unknown).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 19:38:12 +09:00

720 lines
47 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` 으로 자른다 |
**공개 이름은 랩 안에서 안 풀린다.** `auth.hyeonworks.com` 같은 공개 이름이 랩 호스트에서도 게스트에서도 호스트 자신의 tailnet 주소 `100.83.212.4` 로 풀리는데 그 주소에는 443 을 듣는 것이 없다. 이름만 치면 `curl``000` 을 낸다(2026-09-17, 랩 호스트와 `kc-lab-1` 양쪽에서 쳐서 확인했다, observed). 그래서 랩 안에서 치는 `curl` 에는 `--resolve <이름>:443:192.168.122.10` 을 붙여 엣지 게스트를 짚었고, 그 형태로는 정문이 `200` 이다(observed). tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿으므로 `--resolve` 가 필요 없다.
## 이 실험이 가르는 것
암호화 키를 어디에 두고 어떻게 교체하며, 교체하는 동안 옛 키로 저장된 값을 어떻게 읽는가. 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 --resolve auth.hyeonworks.com:443:192.168.122.10 \
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs
```
**`--resolve` 가 붙은 까닭이다**(2026-09-17, observed). 원 가이드는 이름만 쳤는데 `auth.hyeonworks.com` 은 호스트 자신의 tailnet 주소로 풀리고 호스트에는 80 도 443 도 듣는 것이 없다. 엣지 nginx 가 게스트로 옮겨 간 뒤로 그렇다.
```bash label="[lab host] 원 가이드가 적은 형태 — 이 실험대에서는 000 이다"
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs
```
```text label="원 가이드 형태를 랩 호스트에서 쳤을 때"
000
Failed to connect to auth.hyeonworks.com:443 after 22 ms: Could not connect to server
```
tailnet 에 붙은 다른 기계에서 치면 이름 그대로 닿는다. 랩 호스트에서 쳐야 해서 `--resolve` 를 붙였고, 이 편의 `[lab host]` 블록 열두 줄에 전부 같은 것이 걸린다.
줄바꿈 없이 한 줄로 길게 나온다. 실측의 첫머리는 이렇다(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 --resolve auth.hyeonworks.com:443:192.168.122.10 \
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 만 센다"
curl -s --resolve auth.hyeonworks.com:443:192.168.122.10 \
https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs \
| tr '}' '\n' | grep -c RS256
```
Keycloak 자신에게 묻는 쪽이 확실하고 그쪽이 1순위 도구다.
```bash label="[lab host] ② Keycloak 에 직접 묻는다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get keys -r keycloak-patterns
```
**어디를 보나** — 키마다 붙는 `algorithm` 과 `status` 를 보고, `RS256` 이면서 `ACTIVE` 인 것이 지금 서명에 쓰이는 키다.
**2026-09-17 에 둘 다 쳐 봤고 둘 다 돈다**(observed). 원래 실행 기록에는 출력이 없어 미검증으로 두었던 것인데, 이제 값이 있다.
```text label="① 의 출력"
1
```
```text label="② 의 출력에서 kid·status·algorithm 만 뽑은 것"
"kid" : "abfdb1a2-539c-4be6-b651-30e8a8e5c917" "status" : "ACTIVE" "algorithm" : "AES"
"kid" : "24d796a2-ca3c-477c-bf9f-c19f81e0e64c" "status" : "ACTIVE" "algorithm" : "HS512"
"kid" : "HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg" "status" : "ACTIVE" "algorithm" : "RS256"
"kid" : "5voCsAVhALEjGliTG9Z2bx6WSkHeAUFUXiYOYic-niI" "status" : "ACTIVE" "algorithm" : "RSA-OAEP"
```
① 이 내는 `1` 과 3단계의 `kid` 두 줄이 이제 맞아떨어진다. 키는 넷이고 그중 서명용 RS256 이 하나, 암호화용 `RSA-OAEP` 가 하나이며 JWKS 에는 그 둘만 실린다. `AES` 와 `HS512` 는 JWKS 에 안 나온다.
### 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 --resolve auth.hyeonworks.com:443:192.168.122.10 "$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" --resolve app1.hyeonworks.com:443:192.168.122.10 \
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" --resolve app1.hyeonworks.com:443:192.168.122.10 \
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 --resolve auth.hyeonworks.com:443:192.168.122.10 \
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" --resolve app1.hyeonworks.com:443:192.168.122.10 \
https://app1.hyeonworks.com/api/me
curl -s -o /dev/null -w 'new %{http_code}\n' \
-H "Authorization: Bearer $NEW" --resolve app1.hyeonworks.com:443:192.168.122.10 \
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 --resolve auth.hyeonworks.com:443:192.168.122.10 \
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" --resolve app1.hyeonworks.com:443:192.168.122.10 \
https://app1.hyeonworks.com/api/me
curl -s -o /dev/null -w 'new %{http_code}\n' \
-H "Authorization: Bearer $NEW" --resolve app1.hyeonworks.com:443:192.168.122.10 \
https://app1.hyeonworks.com/api/me
```
실측은 이렇다(observed, `03-old-key-removed.txt`).
```text
=== [9] ★ 옛 키로 서명된 토큰은 이제 어떻게 되는가 ===
옛 토큰 /api/me HTTP 401 (캐시가 살아 있으면 아직 통할 수 있다)
새 토큰 /api/me HTTP 200
```
제거는 즉시 반영된다. 괄호 안의 「캐시가 살아 있으면 아직 통할 수 있다」는 측정하기 전에 적어 둔 예상이고, 옆의 401 이 그 예상을 부정한 값이다. 증거 파일에 예상과 결과가 나란히 남아 있다.
**★ 2026-09-17 에 다시 재 보니 그 401 은 절반만 맞았다**(observed). 같은 토큰으로 여덟 번 연속 쳤더니 이렇게 나왔다.
```text label="제거 직후, 같은 토큰으로 여덟 번"
401 200 401 200 401 200 401 200
```
**`echo` 가 replica 둘이고 JWKS 캐시가 인스턴스마다 따로이기 때문이다.** 한쪽은 목록을 새로 받아 옛 키를 잃었고(401) 다른 쪽은 아직 들고 있다(200). Traefik 이 번갈아 보내므로 어느 쪽이 답하느냐에 따라 결과가 갈린다. 파드가 둘 다 `1/1 Running` 인 것은 같은 순간에 확인했다.
한 번만 쳐서는 이것이 안 보인다. 처음 두 번을 쳤을 때 이렇게 갈렸다.
```text label="같은 상태를 한 번씩 쳤을 때"
제거 6초 뒤 처음 친 것 old 200 ← 캐시가 살아 있는 replica
제거 6초 뒤 다시 친 것 old 401 ← 목록을 새로 받은 replica
```
**그래서 이 절의 판정은 「즉시 401」이 아니라 「인스턴스마다 다르다」다.** 운영에서 더 나쁜 형태다 — 옛 토큰을 쥔 사용자가 요청마다 성공과 실패를 오가고, 로그에는 401 이 절반만 남아 재현이 안 되는 장애로 보인다. 원 실행이 「유예가 없다」로 닫은 것은 한 번 친 값이 마침 새로 받은 replica 쪽이었기 때문으로 보인다(inferred).
**replica 를 1 로 줄이면 이 흔들림이 사라진다.** 무엇을 재려는지에 따라 고른다 — 「제거가 반영되는가」를 보려면 1 로, 「운영에서 무엇이 보이는가」를 보려면 2 로 둔다.
**아래 16 절이 이것을 가르는 단계인데, 재시작이 34초 걸려 60초짜리 토큰으로는 전후를 같은 토큰으로 못 견준다**(observed). 재시작 뒤의 `old 401` 은 캐시가 비워져서인지 토큰이 만료돼서인지 갈리지 않는다. 가르려면 `accessTokenLifespan` 을 늘리거나, 위처럼 **재시작 없이 연속으로 쳐서** 두 replica 의 답이 갈리는 것을 보는 편이 빠르다.
**`$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 의 수명 세 값을 받는다"
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get realms/keycloak-patterns --fields accessTokenLifespan,ssoSessionIdleTimeout,ssoSessionMaxLifespan
```
**2026-09-17 에 쳤더니 두 값만 돌아왔다**(observed) — `accessTokenLifespan` 은 `60`, `ssoSessionIdleTimeout` 은 `1800` 이고 `ssoSessionMaxLifespan` 은 이 realm 이 안 내놓는다. 겹침 길이를 정하는 것은 키가 아니라 그 키로 만든 것의 수명이다. 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 에 기반 가이드로 실험대를 새로 세우고 이 편을 어디까지 밟고 멈췄는지 적는다. **못 밟은 것을 밟은 것처럼 적지 않으려고 남긴다.**
- **밟았다** — 키 공급자 추가와 삭제, 그리고 그 둘이 JWKS 와 토큰 `kid` 를 어떻게 바꾸는지 전부. 8·9·10·12·13·14 절이 적어 둔 대로 나왔다(observed). 공급자를 추가하니 RS256 이 1 에서 2 로 늘고 새 토큰의 `kid` 가 `priority` 200 짜리 새 키로 바뀌었으며, 옛 공급자를 지우니 RS256 이 다시 1 이 되고 그 `kid` 가 목록에서 사라졌다.
- **끝까지 밟았다**(2026-09-17). TLS 가 선 뒤 `echo` 가 JWKS 를 받게 되어 7 절의 대조군 `old 200` 이 나왔고, 8~15 절을 토큰 수명 안(6초)에 끝냈다. 결과는 **11 절까지 문서 그대로**(추가는 무중단, `old 200` · `new 200`)이고 **15 절에서 갈렸다** — 위 ★ 를 본다.
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. 와일드카드 인증서(Cloudflare API 토큰이 필요한 DNS-01)가 있어야 한다. 밖에서 닿는 길은 열렸다 — 호스트의 libvirt `guest_input` 구멍과 유닛의 `ExecStartPost` 가 2026-09-17 에 들어갔고 `http` 는 밖에서 `200` 이다(observed).
**11·15 절이 만료를 가르라고 준 두 줄은 이 실험대에서 틀린 답을 낸다**(2026-09-17, observed). `exp` 는 Keycloak 이 게스트 시계로 찍고 `date +%s` 는 랩 호스트 시계로 찍는데, **그 둘이 93초 어긋나 있다.**
```bash label="[lab host] 기계마다 같은 순간에 친다"
date +%s
timedatectl show -p NTP -p NTPSynchronized
```
```text label="같은 순간의 값"
lab host 1789629566 NTP=no NTPSynchronized=no
kc-lab-1 1789629472 NTP=yes NTPSynchronized=yes
kc-lab-2 1789629473 NTP=yes NTPSynchronized=yes
kc-lab-edge 1789629473
```
게스트 셋은 서로 맞고 랩 호스트만 93초 앞선다. 그래서 방금 받은 60초짜리 토큰도 랩 호스트에서 `exp` 를 재면 **이미 33초 전에 만료된 것으로 읽힌다.** D-4 와 D-4a 가 같은 호스트에서 `NTPSynchronized=no` 와 `+106.1` 을 이미 재 두었는데, 이 편은 그것을 모르는 채로 `exp` 비교를 시킨다. 가르려면 두 값을 같은 기계에서 뽑는다 — `kubectl -n keycloak-lab exec keycloak-0 -- date +%s` 로 Keycloak 쪽 시각을 받아 견준다.
- **막는 것** — `https://auth.hyeonworks.com` 이 서지 않는다. **남은 것은 인증서 하나다**(2026-09-17 기준). 같이 적어 두었던 다른 둘은 그날 해결됐다 — 밖에서 닿는 길은 호스트의 libvirt `guest_input` 구멍과 유닛의 `ExecStartPost` 가 들어가면서 열렸고(`http` 가 밖에서 `200`), 클러스터 안에서 그 이름이 엣지를 안 가리키던 것은 기반 가이드 03 의 CoreDNS 한 단계로 놓았다. 인증서는 Cloudflare API 토큰이 필요한 DNS-01 로만 받을 수 있다 — 이름 셋이 tailnet 주소로 풀려 HTTP-01 은 성립하지 않는다.
- **realm 의 수명 세 값은 이제 실측이 있다**(observed). 위에서 미검증으로 표시한 줄을 쳐 보니 `accessTokenLifespan` 은 `60`, `ssoSessionIdleTimeout` 은 `1800` 이다. 세 번째 값 `ssoSessionMaxLifespan` 은 이 realm 이 안 내놓는다.
- **그때까지 이 편의 `(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 -->