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>
47 KiB
id, kind, slug, title, topic, topicName, project, status, studio, pinnedVersions, source, sourceRevision
| id | kind | slug | title | topic | topicName | project | status | studio | pinnedVersions | source | sourceRevision | ||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 7447ccbf-1800-43a4-a9d2-8ac774965c4b | SETUP | reproduce-b6-key-rotation | 서명 키를 더한 뒤 옛 키를 지우고 옛 토큰이 언제 끊기는지 본다 | where-application-state-lives | 세션과 토큰을 Redis 와 PostgreSQL 에 나눠 두기 | keycloak-session-store | 게시 전 | https://hyeonworks.com/studio/documents/7447ccbf-1800-43a4-a9d2-8ac774965c4b/edit |
|
|
cdac9b8178391311d8eca1ebc6cac15bb62d79af |
서명 키를 더한 뒤 옛 키를 지우고 옛 토큰이 언제 끊기는지 본다
realm 에 RSA 서명 키를 하나 더해 겹치는 구간을 만들고, 옛 공급자를 지운 뒤 두 토큰을 같은 두 줄로 다시 치는 절차다. 지운 키는 개인키와 함께 사라져 돌아오지 않으므로 실험대 전용 realm 에서만 한다. 약 15분.
관계
- 볼륨 없는 영속화와 유예 없는 키 회전
이 절차가 만드는
옛 401 · 새 200을 그 기록이 결론으로 적는다. 결론이 필요하면 그쪽을 읽는다. - 주입이 아홉 번 조용히 실패했고 전부 아무 일도 없는 것처럼 보였다
여기서
-q필터가 오류도 종료코드도 없이 빈 결과를 주는 대목이 그 아홉 건 중 하나다. - 예측을 먼저 적고, 주입이 걸렸는지 결과와 따로 확인하고, 대조군 없이 귀속하지 않는다
이 절차는 주입 검증에서
old 200을 보기 전에는 관찰로 넘어가지 않는다. 그것을 안 보고 지우면 뒤에 나온 401 의 원인을 못 가른다. - cookie secret 을 갈아치우고 로그인해 있던 세션이 어떻게 되는지 본다
같은 회전을 식별자가 없는 쪽에서 치는 편이다. 여기서
kid가 겹침을 가능하게 하는 것을 보고 나면 그쪽에서 겹침이 왜 불가능한지가 한 줄로 끝난다.
본문
읽기 전에 — 어디서 치는가
명령은 전부 [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 이 나왔다. 「교체」라는 한 단어가 성질이 정반대인 두 조작을 가리킨다.
키 추가 → 무중단. JWKS 에 옛 키와 새 키가 함께 남는다
키 제거 → ★ 즉시 파괴적. 옛 키로 서명된 토큰이 곧바로 401
절차를 끝까지 밟으면 RS256 키 수가 1 → 2 → 1 로 움직이는 것, 겹치는 구간에서 옛 토큰과 새 토큰이 둘 다 200 인 것, 제거 뒤에 옛 토큰만 401 이 되는 것, 리소스 서버를 재시작해 캐시를 비워도 그 401 이 그대로인 것을 자기 화면에서 보게 된다.
전제와 되돌리기
05-keycloak이 끝나 있고 realmkeycloak-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).
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 절을 치기 전에는 지울 공급자가 없다.
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
delete components/{{NEW_PROVIDER_ID}} -r keycloak-patterns
주입 전에 같은 명령으로 먼저 본다
제거 후에 볼 것을 제거 전에 똑같은 명령으로 먼저 봐 둔다. 넓은 것부터 좁혀 간다.
kcadm 로그인 → 키 공급자 목록 → JWKS 원문 → 토큰의 kid → 그 토큰이 통하는가
1. kcadm 세션을 파드 안에 만든다
목적 — 뒤의 모든 kcadm.sh 명령이 관리 API 로 인증되게 한다.
행동 — 관리자 자격증명으로 로그인하고, 값이 넘어갔는지는 길이로만 본다.
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)"
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 가 무엇인지.
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" 를 지금 적어 둔다. 관찰 절에서 지울 대상이다.
이 값이 뜻하는 것 — 가이드는 「키 공급자만 걸러 보자」는 아래 시도가 빈 결과를 준다고 적고 미검증으로 표시했다.
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 에서 이 질의는 공급자 넷을 그대로 돌려준다.
"name":"rsa-enc-generated" "name":"hmac-generated-hs512" "name":"aes-generated" "name":"rsa-generated"
빈 결과를 봤다면 세션이 없어서일 수 있다. 같은 날 세션이 끊긴 상태로 먼저 쳤을 때는 이렇게 끝났다(observed).
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 원문을 한 번 통째로 본다
무엇을 보는가 — 어떤 필드가 실려 있는지. 다음부터 무엇으로 걸를지가 여기서 정해진다.
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 가 게스트로 옮겨 간 뒤로 그렇다.
curl -s https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs
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).
{"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
그 뒤로 kty · alg · use · n · e 가 이어지고 다음 키가 온다. kid 마다 alg 가 따로 붙는다. 읽을 만하게 자를 때는 jq 가 없으므로 tr 로 쉼표를 줄바꿈으로 바꾼다.
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).
JWKS kid 목록:
{"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
{"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"
이 값이 뜻하는 것 — kid 는 둘인데 같은 파일의 윗줄은 RS256 키가 하나라고 적는다(observed).
JWKS 의 RS256 키 수: 1
세는 단위가 다르다. JWKS 에는 서명 키만 실리지 않는다. 이 realm 에서는 암호화용 키(RSA-OAEP 계열)가 함께 실려 있고 그것도 kid 를 갖는다. grep kid | wc -l 로 세면 서명 키 수를 과다 계산한다.
4. RS256 만 세는 두 형태를 알아 둔다
무엇을 보는가 — 알고리즘까지 보고 세는 방법. 아래 두 줄은 가이드가 미검증으로 표시했다(unknown).
JWKS 는 키 하나가 } 로 끝나므로 tr '}' 로 자르면 한 줄이 한 키가 된다.
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순위 도구다.
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). 원래 실행 기록에는 출력이 없어 미검증으로 두었던 것인데, 이제 값이 있다.
1
"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 로 받는다.
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).
2043자
왜 필요한가 — 변수 이름이 OLD 인 까닭은 회전이 끝난 뒤에도 이 값이 「옛 키로 서명된 토큰」으로 남아 있어야 하기 때문이다. 중간에 다시 받으면 새 키로 서명되어 실험이 성립하지 않는다. 토큰 값도 클라이언트 비밀도 화면에 찍지 않고 길이만 본다.
문제가 생기면 — 0자 가 나오면 토큰을 못 받았다. 변수에 담지 말고 같은 curl 을 그대로 쳐서 응답 본문을 읽는다.
6. 그 토큰이 어느 키로 서명됐는지 읽는다
무엇을 보는가 — JWT 의 첫 토막이 헤더이고 거기 kid 가 있다.
echo "$OLD" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
어디를 보나 — 원래 실행의 모양은 이렇다(observed).
{"alg":"RS256","typ" : "JWT","kid" : "OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"}
콜론 양옆의 공백을 눈여겨본다. Keycloak 은 토큰 헤더를 "typ" : "JWT" 처럼 공백을 넣어 찍고, 페이로드는 "sid":"…" 처럼 붙여 찍는다. 그래서 페이로드에서 되던 sed 가 헤더에서는 빈손으로 돌아온다. 2026-09-17 에 같은 토큰 하나로 두 형태를 나란히 쳤다(observed).
헤더: {"alg":"RS256","typ" : "JWT","kid" : "HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg"}
'"kid":"' 로 뽑으면 : []
'"kid" *: *"' 로 뽑으면: [HKy0uQhg-vlQackK6-oj3hW6vKbDj-95Wlvdgl37cGg]
헤더에서 값을 뽑을 때는 공백을 허용한다.
echo "$OLD" | cut -d. -f1 | tr '_-' '/+' | base64 -d 2>/dev/null \
| sed -n 's/.*"kid" *: *"\([^"]*\)".*/\1/p'
증거 파일에는 이렇게 남아 있다(observed, 01-before-rotation.txt).
발급 토큰의 kid: OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM
이 값이 뜻하는 것 — kid 는 key ID 이고, 서명한 쪽이 어느 키를 썼는지 토큰 헤더에 적어 준다. 검증하는 쪽은 JWKS 에서 그 kid 를 찾아 공개키를 얻는다. kid 가 없다면 검증자는 「지금 유효한 키」 하나만 알 수 있고, 키가 바뀌는 순간 옛 토큰이 전부 죽는다. 겹치는 구간을 가능하게 하는 것이 이 kid 다. 여기서 본 값이 3 절의 목록에 있는지 대조한다. base64 패딩 때문에 끝이 깨져 보일 수 있고(2>/dev/null 이 그 불평을 지운다) 헤더는 짧아서 대개 온전히 보인다.
7. 그 토큰이 지금 통하는지 본다
무엇을 보는가 — 대조군. 이 확인을 건너뛰면 뒤의 401 이 아무 의미가 없다.
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 이 났을 때 왜인지 물을 근거가 생긴다. 여러 번 비교할 때부터는 코드만 뽑는다.
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).
=== [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 가 더 큰 공급자를 하나 더 만든다.
t0 키 A 만 있다. 발급: A, 검증: A
t1 키 B 추가. 발급: B, 검증: A + B ← 겹치는 구간
t2 키 A 제거. 발급: B, 검증: B
행동 — 공급자를 만들고 시각을 남긴다.
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).
=== [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 에 옛 키가 남아 있는가
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).
=== [4] 회전 후 JWKS — 옛 키가 남아 있는가 ===
RS256 키 수: 2
kid 목록:
{"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
{"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"
옛 kid(OY-caYDN…)가 목록에서 빠지지 않았다. 새 것이 하나 늘었고 아무것도 사라지지 않았다. JWKS 는 지금 검증에 쓸 수 있는 키 전부를 싣는 목록이고, 추가는 그 목록을 늘린다.
10. 새 토큰은 어느 키로 서명되는가
OLD 은 건드리지 않는다.
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).
=== [5] 새 토큰은 어느 키로 서명되는가 ===
새 토큰의 kid: 1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84
kid 가 우선순위 200 짜리 새 키로 바뀌었다. 발급은 우선순위가 가장 높은 키로 간다. 여기서 kid 가 안 바뀌었다면 priority 를 낮게 준 것이다.
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, 02-rotation.txt).
=== [6] ★ 회전 전에 발급된 토큰은 아직 통하는가 ===
옛 토큰 /api/me HTTP 200
새 토큰 /api/me HTTP 200
둘 다 200 이므로 키 추가는 무중단이다. 새 토큰은 새 키로 서명되고 옛 토큰은 JWKS 에 아직 있는 옛 키로 검증되며 사용자는 아무것도 못 느낀다.
여기서 old 가 401 이면 둘 중 하나다. 토큰이 만료됐거나(60초), 추가 말고 다른 것을 건드렸다. 가르는 법은 옛 토큰의 exp 를 보는 것이고 JWT 의 가운데 토막이 클레임이다.
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 는 여전히 안 먹는다.
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
get components -r keycloak-patterns --fields id,name,providerId
목록이 길면 그 항목 둘레만 잘라 본다. "id" 는 "name" 보다 위에 나온다.
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 을 내 결론이 뒤집힌다.
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).
=== [7] 옛 RSA 공급자(980ee9b7 = OY-caYDN 키) 제거 ===
제거 완료
왜 필요한가 — 시각을 적어 두면 뒤에 나온 401 을 이 조작에 귀속할 수 있다. 그리고 지워진 것은 공급자이므로 그 안의 개인키도 함께 사라진다.
문제가 생기면 — 지운 뒤 새 토큰까지 401 이면 새 공급자를 지운 것이다. 14 절과 15 절을 먼저 치고 kid 를 대조한다.
14. JWKS 에서 사라졌는지 본다
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).
=== [8] JWKS 에서 사라졌는가 ===
RS256 키 수: 1
{"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
OY-caYDN… 이 없고 RS256 은 다시 하나다. gokjn0… 은 처음부터 끝까지 목록에 있는데, 서명 키가 아니라 암호화 키이기 때문이다.
15. 두 토큰을 같은 두 줄로 다시 친다
치기 전에 $OLD 가 아직 안 죽었는지 본다. 11 절의 만료 확인 두 줄을 그대로 다시 친다. exp 가 date +%s 보다 작으면 아래에서 나올 old 401 은 키 제거가 아니라 만료이고, 두 401 은 화면에서 똑같이 보인다.
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).
=== [9] ★ 옛 키로 서명된 토큰은 이제 어떻게 되는가 ===
옛 토큰 /api/me HTTP 401 (캐시가 살아 있으면 아직 통할 수 있다)
새 토큰 /api/me HTTP 200
제거는 즉시 반영된다. 괄호 안의 「캐시가 살아 있으면 아직 통할 수 있다」는 측정하기 전에 적어 둔 예상이고, 옆의 401 이 그 예상을 부정한 값이다. 증거 파일에 예상과 결과가 나란히 남아 있다.
★ 2026-09-17 에 다시 재 보니 그 401 은 절반만 맞았다(observed). 같은 토큰으로 여덟 번 연속 쳤더니 이렇게 나왔다.
401 200 401 200 401 200 401 200
echo 가 replica 둘이고 JWKS 캐시가 인스턴스마다 따로이기 때문이다. 한쪽은 목록을 새로 받아 옛 키를 잃었고(401) 다른 쪽은 아직 들고 있다(200). Traefik 이 번갈아 보내므로 어느 쪽이 답하느냐에 따라 결과가 갈린다. 파드가 둘 다 1/1 Running 인 것은 같은 순간에 확인했다.
한 번만 쳐서는 이것이 안 보인다. 처음 두 번을 쳤을 때 이렇게 갈렸다.
제거 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 이 캐시 상태 때문인지 가른다.
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).
=== [10] 리소스 서버를 재시작해 JWKS 캐시를 비우면 ===
deployment "echo" successfully rolled out
옛 토큰 /api/me HTTP 401
새 토큰 /api/me HTTP 200
왜 필요한가 — 재시작 전과 후가 같으므로 401 은 캐시 상태와 무관하고 캐시는 유예를 주지 않았다. Spring 의 NimbusJwtDecoder 는 모르는 kid 를 만나면 JWKS 를 다시 가져온다. 캐시는 이미 아는 키를 다시 안 받으려는 장치이지 옛 키를 붙잡아 두는 장치가 아니다.
옛 토큰 도착
│
├─▶ 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).
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분어치의 토큰을 죽인다. 토큰 저장소의 암호화 키를 나중에 설계할 때도 같은 모양이 된다.
쓰기: 새 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가priority200 짜리 새 키로 바뀌었으며, 옛 공급자를 지우니 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)가 있어야 한다. 밖에서 닿는 길은 열렸다 — 호스트의 libvirtguest_input구멍과 유닛의ExecStartPost가 2026-09-17 에 들어갔고http는 밖에서200이다(observed).
11·15 절이 만료를 가르라고 준 두 줄은 이 실험대에서 틀린 답을 낸다(2026-09-17, observed). exp 는 Keycloak 이 게스트 시계로 찍고 date +%s 는 랩 호스트 시계로 찍는데, 그 둘이 93초 어긋나 있다.
date +%s
timedatectl show -p NTP -p NTPSynchronized
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 기준). 같이 적어 두었던 다른 둘은 그날 해결됐다 — 밖에서 닿는 길은 호스트의 libvirtguest_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:30–14:32 KST 에 돈 한 번의 실행에서 나왔다(observed). 해설 문서 머리의 15:50–16:00 KST 는 문서를 쓴 시각이고 증거 파일의 mtime 이 앞의 값이라, 실측으로 인용하는 것은 뒤쪽이라고 가이드가 적는다.
- (observed) 회전 전 JWKS 의
kid두 개(gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4·OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM)와 RS256 키 수 1, 발급 토큰의kid가OY-caYDN…인 것, 그 토큰의/api/me HTTP 200, 추가한 공급자의 id7902af43-a0cc-4ebd-ad25-04d563854d16, 회전 후 RS256 키 수 2 와 늘어난kid1B4AQHoxZvFaQi1tc1byz8ifU-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 이 제거 뒤에 어떻게 되는지는 측정하지 않았다.