Compare commits

..
Author SHA1 Message Date
DongHyeonkaandClaude Opus 5 bcb563a04e docs: B-7 — the cookie secret has no overlap window and rotation orphans sessions
oauth2-proxy carries the authorization request in a signed cookie, so the callback can land on a different replica and still succeed, which is the opposite of the BFF failure in B-0. Sharing is therefore just sharing one Secret.

Rotating it is all-or-nothing: --cookie-secret is singular, so there is no second key to read old tickets with, and the log shows both the validation failure and Error removing session, leaving the Redis session orphaned because the key cannot be derived from a ticket that will not decode.

Getting there required two diagnoses: the callback 502 came from the full session riding in Set-Cookie past nginx's buffer, and every earlier attempt to read nginx config returned nothing because sudo on the host asks for a password while the guests do not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:44:00 +09:00
DongHyeonkaandClaude Opus 5 aa2c3907f5 docs: B-6 — rotation is safe, retiring the old key is not
Adding a higher-priority RSA provider leaves both kids in JWKS, so tokens signed before and after the rotation both validate. Deleting the old provider makes its tokens 401 immediately, and the resource server's JWKS cache does not buy a grace period because an unknown kid triggers a refetch.

The encryption key Q3 asks about does not exist yet, since B-2 showed the tokens are stored as plaintext JWTs, so the measured signing-key rotation is what its design has to copy: write with one key, read with several, and keep the overlap longer than the lifetime of anything signed with the old one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:33:42 +09:00
22 changed files with 756 additions and 0 deletions
@@ -0,0 +1,2 @@
[ 275ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://app2.hyeonworks.com/oauth2/callback?state=G2-BDWkehNWO7hGwhYCxXBVRKZ6AomLIrSLBtIXr0Gw%3A%2Fapi%2Fecho&session_state=vsW8xDlLJN3DUl-0B-X1WL7Q&iss=https%3A%2F%2Fauth.hyeonworks.com%2Frealms%2Fkeycloak-patterns&code=4155f58e-6a58-e47b-93bd-7e2b625c2b91.vsW8xDlLJN3DUl-0B-X1WL7Q.80431dbc-af81-4673-9790-ad06d1570b2e:0
[ 408ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://app2.hyeonworks.com/oauth2/callback?state=Da-7OcMB4f7vyHgr-6CqrTtJpmj1R_SfRfcE3CUJNzE%3A%2Ffavicon.ico&session_state=vsW8xDlLJN3DUl-0B-X1WL7Q&iss=https%3A%2F%2Fauth.hyeonworks.com%2Frealms%2Fkeycloak-patterns&code=96d66247-91b0-0cc1-89dc-e527c2b69bf2.vsW8xDlLJN3DUl-0B-X1WL7Q.80431dbc-af81-4673-9790-ad06d1570b2e:0
@@ -0,0 +1,2 @@
[ 266ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://app2.hyeonworks.com/oauth2/callback?state=TZnQvjIWCEySrf4PMg2WLVFoOfkyOJWB58f2LGjVfpo%3A%2Fapi%2Fecho&session_state=vsW8xDlLJN3DUl-0B-X1WL7Q&iss=https%3A%2F%2Fauth.hyeonworks.com%2Frealms%2Fkeycloak-patterns&code=8ae913a1-2647-0ed9-625e-3d80e1565024.vsW8xDlLJN3DUl-0B-X1WL7Q.80431dbc-af81-4673-9790-ad06d1570b2e:0
[ 416ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://app2.hyeonworks.com/oauth2/callback?state=uAYwZp59ncz95XjkJXAlIgsT7oksBQBViiQS07t2eow%3A%2Ffavicon.ico&session_state=vsW8xDlLJN3DUl-0B-X1WL7Q&iss=https%3A%2F%2Fauth.hyeonworks.com%2Frealms%2Fkeycloak-patterns&code=7b7fc816-0b27-93a0-83b8-656488813253.vsW8xDlLJN3DUl-0B-X1WL7Q.80431dbc-af81-4673-9790-ad06d1570b2e:0
@@ -0,0 +1,2 @@
[ 287ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://app2.hyeonworks.com/oauth2/callback?state=0EOFj1PoLyPil0dgukpi7zKW4JKnGZTP9Wj6EhTR-lw%3A%2Fapi%2Fecho&session_state=vsW8xDlLJN3DUl-0B-X1WL7Q&iss=https%3A%2F%2Fauth.hyeonworks.com%2Frealms%2Fkeycloak-patterns&code=d13cc206-133f-00a9-9908-599988c4d7cf.vsW8xDlLJN3DUl-0B-X1WL7Q.80431dbc-af81-4673-9790-ad06d1570b2e:0
[ 457ms] [ERROR] Failed to load resource: the server responded with a status of 502 () @ https://app2.hyeonworks.com/oauth2/callback?state=kbYC5O4_ELsoQFw85vyYfgWEqlI2yWImB4rmelbmSTM%3A%2Ffavicon.ico&session_state=vsW8xDlLJN3DUl-0B-X1WL7Q&iss=https%3A%2F%2Fauth.hyeonworks.com%2Frealms%2Fkeycloak-patterns&code=91f9fde9-f376-b6f3-4622-a1ba674cc4fd.vsW8xDlLJN3DUl-0B-X1WL7Q.80431dbc-af81-4673-9790-ad06d1570b2e:0
@@ -0,0 +1 @@
[ 307ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/favicon.ico:0
@@ -0,0 +1 @@
[ 261ms] [ERROR] Failed to load resource: the server responded with a status of 401 () @ https://app2.hyeonworks.com/favicon.ico:0
@@ -0,0 +1,4 @@
- generic [active] [ref=f40e1]:
- heading "502 Bad Gateway" [level=1] [ref=f40e3]
- separator [ref=f40e4]
- generic [ref=f40e5]: nginx/1.30.4
@@ -0,0 +1,4 @@
- generic [active] [ref=f41e1]:
- heading "502 Bad Gateway" [level=1] [ref=f41e3]
- separator [ref=f41e4]
- generic [ref=f41e5]: nginx/1.30.4
@@ -0,0 +1,4 @@
- generic [active] [ref=f42e1]:
- heading "502 Bad Gateway" [level=1] [ref=f42e3]
- separator [ref=f42e4]
- generic [ref=f42e5]: nginx/1.30.4
@@ -0,0 +1 @@
- generic [active] [ref=f43e1]: "{ \"headers\" : { \"host\" : [ \"app2.hyeonworks.com\" ], \"user-agent\" : [ \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36\" ], \"accept\" : [ \"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7\" ], \"accept-encoding\" : [ \"gzip, deflate, br, zstd\" ], \"accept-language\" : [ \"en-US,en;q=0.9\" ], \"cookie\" : [ \"grafana_session=60c3e7ae41ffc00665f4a2c377def399; grafana_session_expiry=1788497124; _oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMWlNall4TVRGbVltUXhabVJoWWpOaFpUSXhPREpsTWpnM01EQXhZakF5WVEuTk81VE82RHRod2NWZWstaHpPZVg1Zw==|1788500470|iPSRUlwHDB0XgC6sUdU4dq1EHq9WQDPYrDoezajKVUA=\" ], \"priority\" : [ \"u=0, i\" ], \"sec-ch-ua\" : [ \"\\\"Chromium\\\";v=\\\"152\\\", \\\"Not?A_Brand\\\";v=\\\"24\\\", \\\"Google Chrome\\\";v=\\\"152\\\"\" ], \"sec-ch-ua-mobile\" : [ \"?0\" ], \"sec-ch-ua-platform\" : [ \"\\\"Linux\\\"\" ], \"sec-fetch-dest\" : [ \"document\" ], \"sec-fetch-mode\" : [ \"navigate\" ], \"sec-fetch-site\" : [ \"none\" ], \"sec-fetch-user\" : [ \"?1\" ], \"upgrade-insecure-requests\" : [ \"1\" ], \"x-forwarded-email\" : [ \"labuser@example.com\" ], \"x-forwarded-host\" : [ \"app2.hyeonworks.com\" ], \"x-forwarded-port\" : [ \"443\" ], \"x-forwarded-preferred-username\" : [ \"labuser\" ], \"x-forwarded-proto\" : [ \"https\" ], \"x-forwarded-server\" : [ \"traefik-5d6fcf895-wpfhr\" ], \"x-forwarded-user\" : [ \"27df5ea9-8703-4ec5-badd-d972c583e1ff\" ], \"x-real-ip\" : [ \"100.123.124.30\" ] }, \"remoteAddr\" : \"100.123.124.30\", \"localAddr\" : \"10.42.0.53\", \"scheme\" : \"https\", \"secure\" : true, \"serverName\" : \"app2.hyeonworks.com\", \"serverPort\" : 443, \"requestUrl\" : \"https://app2.hyeonworks.com/api/echo\" }"
@@ -0,0 +1 @@
- generic [active] [ref=f44e1]: "{ \"headers\" : { \"host\" : [ \"app2.hyeonworks.com\" ], \"user-agent\" : [ \"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36\" ], \"accept\" : [ \"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7\" ], \"accept-encoding\" : [ \"gzip, deflate, br, zstd\" ], \"accept-language\" : [ \"en-US,en;q=0.9\" ], \"cookie\" : [ \"grafana_session=60c3e7ae41ffc00665f4a2c377def399; grafana_session_expiry=1788497124; _oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMDVOemhrWm1GbFptSmtZV1JqWTJJNU5tTTNZbVV4TmpJMVpHSmhOVFl4TmcucmoxSnJPYjJKOW1ZV191aXVWa2FCZw==|1788500538|RoiStOeQcIDldxB3cckyO-OAiMgBjBfw5gOSvUsgTFU=\" ], \"priority\" : [ \"u=0, i\" ], \"sec-ch-ua\" : [ \"\\\"Chromium\\\";v=\\\"152\\\", \\\"Not?A_Brand\\\";v=\\\"24\\\", \\\"Google Chrome\\\";v=\\\"152\\\"\" ], \"sec-ch-ua-mobile\" : [ \"?0\" ], \"sec-ch-ua-platform\" : [ \"\\\"Linux\\\"\" ], \"sec-fetch-dest\" : [ \"document\" ], \"sec-fetch-mode\" : [ \"navigate\" ], \"sec-fetch-site\" : [ \"none\" ], \"sec-fetch-user\" : [ \"?1\" ], \"upgrade-insecure-requests\" : [ \"1\" ], \"x-forwarded-email\" : [ \"labuser@example.com\" ], \"x-forwarded-host\" : [ \"app2.hyeonworks.com\" ], \"x-forwarded-port\" : [ \"443\" ], \"x-forwarded-preferred-username\" : [ \"labuser\" ], \"x-forwarded-proto\" : [ \"https\" ], \"x-forwarded-server\" : [ \"traefik-5d6fcf895-wpfhr\" ], \"x-forwarded-user\" : [ \"27df5ea9-8703-4ec5-badd-d972c583e1ff\" ], \"x-real-ip\" : [ \"100.123.124.30\" ] }, \"remoteAddr\" : \"100.123.124.30\", \"localAddr\" : \"10.42.1.132\", \"scheme\" : \"https\", \"secure\" : true, \"serverName\" : \"app2.hyeonworks.com\", \"serverPort\" : 443, \"requestUrl\" : \"https://app2.hyeonworks.com/api/echo\" }"
+128
View File
@@ -0,0 +1,128 @@
# Experiment B-7 — oauth2-proxy, to measure how replicas share a cookie secret
# and what happens when it is rotated (Q1, unknown 7).
#
# This is a different shape of problem from the BFF. The BFF keeps state on the
# server, so the question was "which store". oauth2-proxy keeps no server state
# at all: the whole session rides in a cookie that is signed and encrypted with
# --cookie-secret. So there is nothing to share and nothing to lose on restart —
# instead, every replica must hold the *same* secret, and changing it invalidates
# every cookie at once.
#
# kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml
#
# app2.hyeonworks.com is borrowed from Grafana for the duration of this
# experiment; the certificate only covers auth / app1 / app2, so a fourth name
# is not available. Grafana's Ingress is restored afterwards.
apiVersion: v1
kind: Secret
metadata:
name: oauth2-proxy-secrets
namespace: keycloak-lab
type: Opaque
stringData:
# oauth2-proxy requires exactly 16, 24 or 32 bytes. This is the value whose
# rotation the experiment is about.
COOKIE_SECRET_A: "lab-cookie-secret-aaaaaaaaaaaaaa"
COOKIE_SECRET_B: "lab-cookie-secret-bbbbbbbbbbbbbb"
CLIENT_SECRET: proxy-lab-secret
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: oauth2-proxy
namespace: keycloak-lab
spec:
# Two replicas is the point: Q1 asks how they share the secret.
replicas: 2
selector:
matchLabels: { app: oauth2-proxy }
template:
metadata:
labels: { app: oauth2-proxy }
spec:
# See B-1: Kubernetes injects <SVCNAME>_PORT as a tcp:// URL and it
# collides with ordinary configuration names.
enableServiceLinks: false
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels: { app: oauth2-proxy }
containers:
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.7.1
args:
- --provider=oidc
- --oidc-issuer-url=https://auth.hyeonworks.com/realms/keycloak-patterns
- --client-id=oauth2-proxy
- --redirect-url=https://app2.hyeonworks.com/oauth2/callback
- --email-domain=*
- --http-address=0.0.0.0:4180
# The upstream is the same echo app the B-4 header experiment used,
# so what the proxy forwards can be read straight off the response.
- --upstream=http://echo.header-lab.svc:8081
# ★ 이 옵션을 켜면 세션(=쿠키)에 access token 이 들어간다.
# 그러면 Set-Cookie 가 커져 프록시 앞단에서 502 가 났다.
# B-4 에서 본 헤더 크기 절벽이 이번에는 응답 쪽에서 나타난 것이다.
# - --pass-authorization-header=true
- --set-xauthrequest=true
- --reverse-proxy=true
- --cookie-secure=true
# One hour, matching the value Q1 records for the current setup.
- --cookie-expire=1h
- --skip-provider-button=true
# ★ 쿠키에 세션 전체를 담으면 Set-Cookie 가 커지고, 그 응답이
# 앞단 nginx 의 proxy_buffer 를 넘겨 502 가 났다(측정됨).
# Redis 로 옮기면 쿠키에는 티켓만 남는다 — 그리고 그 순간
# "replica 가 secret 을 공유해야 한다"는 문제의 성격도 바뀐다.
- --session-store-type=redis
- --redis-connection-url=redis://redis.keycloak-lab.svc:6379
env:
- name: OAUTH2_PROXY_CLIENT_SECRET
valueFrom:
secretKeyRef: { name: oauth2-proxy-secrets, key: CLIENT_SECRET }
# Which of the two secrets is in use is switched here. Both replicas
# read the same key, which is exactly the sharing Q1 asks about.
- name: OAUTH2_PROXY_COOKIE_SECRET
valueFrom:
secretKeyRef: { name: oauth2-proxy-secrets, key: COOKIE_SECRET_A }
ports:
- containerPort: 4180
name: http
readinessProbe:
httpGet: { path: /ping, port: http }
initialDelaySeconds: 5
resources:
requests: { memory: 32Mi, cpu: 20m }
limits: { memory: 128Mi }
---
apiVersion: v1
kind: Service
metadata:
name: oauth2-proxy
namespace: keycloak-lab
spec:
selector: { app: oauth2-proxy }
ports:
- port: 4180
targetPort: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: oauth2-proxy
namespace: keycloak-lab
spec:
ingressClassName: traefik
rules:
- host: app2.hyeonworks.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: oauth2-proxy
port:
number: 4180
@@ -0,0 +1,9 @@
=== [1] 회전 전: 토큰 발급 + JWKS 상태 ===
발급 토큰의 kid: OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM
JWKS 의 RS256 키 수: 1
JWKS kid 목록:
{"keys":[{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
{"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"
=== [2] 그 토큰이 지금 통하는가 (리소스 서버) ===
/api/me HTTP 200
@@ -0,0 +1,16 @@
=== [3] 키 회전 — 우선순위가 더 높은 RSA 공급자를 추가한다 ===
Created new component with id '7902af43-a0cc-4ebd-ad25-04d563854d16'
=== [4] 회전 후 JWKS — 옛 키가 남아 있는가 ===
RS256 키 수: 2
kid 목록:
{"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
{"kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"
=== [5] 새 토큰은 어느 키로 서명되는가 ===
새 토큰의 kid: 1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84
=== [6] ★ 회전 전에 발급된 토큰은 아직 통하는가 ===
옛 토큰 /api/me HTTP 200
새 토큰 /api/me HTTP 200
@@ -0,0 +1,16 @@
=== [7] 옛 RSA 공급자(980ee9b7 = OY-caYDN 키) 제거 ===
제거 완료
=== [8] JWKS 에서 사라졌는가 ===
RS256 키 수: 1
{"keys":[{"kid":"1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84"
{"kid":"gokjn0zFUok8r7JVqW1cxuyojH1bTT87vzfQG9RrFX4"
=== [9] ★ 옛 키로 서명된 토큰은 이제 어떻게 되는가 ===
옛 토큰 /api/me HTTP 401 (캐시가 살아 있으면 아직 통할 수 있다)
새 토큰 /api/me HTTP 200
=== [10] 리소스 서버를 재시작해 JWKS 캐시를 비우면 ===
deployment "echo" successfully rolled out
옛 토큰 /api/me HTTP 401
새 토큰 /api/me HTTP 200
+16
View File
@@ -0,0 +1,16 @@
# B-6 — key 회전 증거
2026-09-04 15:5016:00 KST
해설: [`docs/experiment-b6-key-rotation.md`](../../experiment-b6-key-rotation.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-before-rotation.txt` | 회전 전 — 토큰 `kid=OY-caYDN...`, JWKS RS256 1개, `/api/me` 200 |
| `02-rotation.txt` | 우선순위 200 공급자 추가 → **JWKS RS256 2개**, 새 토큰은 새 kid, **옛 토큰도 새 토큰도 200** (무중단) |
| `03-old-key-removed.txt` | 옛 공급자 제거 → JWKS 1개, **옛 토큰 즉시 401**. 리소스 서버 재시작 후에도 동일 |
## 핵심 세 줄
1. **Keycloak 의 키 회전은 "바꾸기"가 아니라 "더 높은 우선순위로 추가하기"** 다. 추가만으로는 아무것도 안 깨진다.
2. **위험한 것은 옛 키를 버리는 시점이다.** 제거 즉시 그 키로 서명된 토큰이 401 이 된다.
3. **캐시는 유예가 아니다.** 모르는 `kid` 를 만나면 JWKS 를 다시 받으므로 제거가 곧바로 반영된다. 유예는 옛 키를 남겨두는 기간으로 만들어야 한다.
@@ -0,0 +1,13 @@
=== Grafana ingress 를 잠시 내린다 (app2 를 빌린다) ===
grafana ingress 삭제
secret/oauth2-proxy-secrets created
deployment.apps/oauth2-proxy created
service/oauth2-proxy created
ingress.networking.k8s.io/oauth2-proxy created
deployment "oauth2-proxy" successfully rolled out
oauth2-proxy-c76b49c59-8p5hl true kc-lab-1
oauth2-proxy-c76b49c59-b9928 true kc-lab-2
=== 진입점 확인 ===
https://app2.hyeonworks.com/ HTTP 302
/ping HTTP 200
@@ -0,0 +1,9 @@
=== curl 로 OAuth 흐름을 완주한다 (nginx 우회, Traefik 직접) ===
로그인 폼 action: https://auth.hyeonworks.com/realms/keycloak-patterns/login-actions/authenticate?session_co...
쿠키 항아리:
len=0
KC_AUTH_SESSION_HASH len=64
=== 두 replica 모두 이 쿠키를 받아들이는가 ===
10.42.1.135 /oauth2/auth HTTP 000
10.42.1.134 /oauth2/auth HTTP 000
@@ -0,0 +1,31 @@
=== 세션이 Redis 에 들어갔는가 ===
b5:pvc
_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a
dbsize: 2
=== oauth2-proxy 가 cookie secret 을 여러 개 받는가 ===
--cookie-secret string the seed string for secure cookies (optionally base64 encoded)
=== ★ secret 을 A → B 로 교체한다 ===
deployment.apps/oauth2-proxy patched
deployment "oauth2-proxy" successfully rolled out
현재 secret 키: COOKIE_SECRET_B
Redis 세션은 그대로인가: 2 키
=== secret 교체 후 oauth2-proxy 로그 — 옛 쿠키를 어떻게 처리했나 ===
[2026/09/04 05:41:46] [oauthproxy.go:178] Cookie settings: name:_oauth2_proxy secure(https):true httponly:true expiry:1h0m0s domains: path:/ samesite: refresh:disabled
[2026/09/04 05:42:18] [oauthproxy.go:1024] No valid authentication in request. Initiating login.
100.123.124.30 - cb8c0ec1-1d87-479c-9aef-e1d9158a5829 - - [2026/09/04 05:42:18] app2.hyeonworks.com GET - "/api/echo" HTTP/1.1 "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, lik
[2026/09/04 05:42:18] [stored_session.go:94] Error loading cookied session: session ticket cookie failed validation: <nil>, removing session
[2026/09/04 05:42:18] [stored_session.go:97] Error removing session: error decoding ticket to clear session: session ticket cookie failed validation: <nil>
100.123.124.30 - 28af938f-08b5-4e15-9094-8d9591a18a3f - labuser@example.com [2026/09/04 05:42:18] app2.hyeonworks.com GET / "/api/echo" HTTP/1.1 "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/
[2026/09/04 05:41:58] [providers.go:146] Warning: Your provider supports PKCE methods ["plain" "S256"], but you have not enabled one with --code-challenge-method
[2026/09/04 05:41:58] [oauthproxy.go:172] OAuthProxy configured for OpenID Connect Client ID: oauth2-proxy
[2026/09/04 05:41:58] [oauthproxy.go:178] Cookie settings: name:_oauth2_proxy secure(https):true httponly:true expiry:1h0m0s domains: path:/ samesite: refresh:disabled
100.123.124.30 - 5a08219f-60e0-4c97-bfee-78cae8891ca8 - labuser@example.com [2026/09/04 05:42:18] [AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com user:27df5ea9-8703
100.123.124.30 - 5a08219f-60e0-4c97-bfee-78cae8891ca8 - - [2026/09/04 05:42:18] app2.hyeonworks.com GET - "/oauth2/callback?state=j7eKInWCrYqRyi5LVDGDjtLrIBJCpwdkmzoJVdhJUc0%3A%2Fapi%2Fecho&
100.123.124.30 - a3074807-5143-49b9-b77c-e7e2eb90ac24 - labuser@example.com [2026/09/04 05:42:18] app2.hyeonworks.com GET / "/favicon.ico" HTTP/1.1 "Mozilla/5.0 (X11; Linux x86_64) AppleWebK
=== Redis 세션 수 (옛 세션이 남아 있는가) ===
_oauth2_proxy-978dfaefbdadccb96c7be1625dba5616
_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a
총: 2 개
+18
View File
@@ -0,0 +1,18 @@
# B-7 — oauth2-proxy cookie secret 교체 증거
2026-09-04 16:0016:15 KST
해설: [`docs/experiment-b7-cookie-secret-rotation.md`](../../experiment-b7-cookie-secret-rotation.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-deploy.txt` | 양 노드에 replica 하나씩. `/` 302, `/ping` 200 |
| `02-cookie-portability.txt` | curl 로 흐름을 완주하려던 시도 — 파드 IP 는 호스트에서 안 닿는다 |
| `03-rotation.txt` | **`--cookie-secret string` 단수 확인** · 교체 후 `session ticket cookie failed validation` · **`Error removing session`** · Redis 에 **고아 세션 2개** |
| `b7-oauth2proxy-login-success.png` | Redis 세션 전환 후 성공한 Forward-Auth — `x-forwarded-user/email/preferred-username`**티켓 형태 쿠키** |
## 핵심 네 줄
1. **BFF 와 정반대다.** 인가 요청이 쿠키에 있어 **콜백이 다른 replica 로 가도 성공**한다 — B-0 에서 BFF 가 실패한 바로 그 지점.
2. **502 의 원인은 큰 쿠키였다.** Traefik 직접은 정상이고 nginx 만 502 — B-4 의 헤더 절벽이 응답 쪽에서 재현됐다.
3. **겹침 구간을 만들 수 없다.** `--cookie-secret` 이 단수라 B-6 의 무중단 회전이 불가능하다.
4. **교체하면 서버 세션이 고아로 남는다.** 티켓을 못 푸니 지울 수도 없다.
Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

+228
View File
@@ -0,0 +1,228 @@
# B-6 — key 를 교체하면 옛 것으로 만든 것은 어떻게 되는가 → Q3 미지수 3
브랜치 `feature/keycloak-b6-key-rotation` ·
증거 [`docs/evidence/b6-key-rotation/`](evidence/b6-key-rotation/) ·
2026-09-04 15:5016:00 KST
선행: [`B-2`](experiment-b2-multi-instance-session.md) — 토큰이 **평문**임을 확인했다
**대응 질문** — Q3 미지수 3
> *"암호화 key 를 어디에 두고 어떻게 교체하게 되는가. 교체하는 동안 이전 key 로
> 저장된 값은 어떻게 읽는가."*
---
## 0. 결론부터
**질문이 두 갈래로 나뉜다.**
| | 상태 |
|---|---|
| **① 토큰 저장소의 암호화 key** | **★ 존재하지 않는다.** B-2 에서 평문임을 확인했다 |
| **② 토큰 서명 key (Keycloak realm)** | 존재하고 회전 가능하다 — **측정했다** |
②를 측정한 결과가 ①을 설계할 때 그대로 쓰인다.
```
키 추가 → 무중단. JWKS 에 옛 키와 새 키가 함께 남는다
키 제거 → ★ 즉시 파괴적. 옛 키로 서명된 토큰이 곧바로 401
```
**"교체하는 동안 이전 key 로 저장된 값은 어떻게 읽는가" 의 답은
"두 key 를 동시에 들고 있으면 읽힌다" 이고, 위험한 것은 교체가 아니라
옛 key 를 언제 버리느냐다.**
---
## 1. ① 암호화 key 는 애초에 없다
B-2 에서 확인했다.
```sql
select convert_from(refresh_token_value, 'UTF8') from oauth2_authorized_client;
eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJlMmUz...
```
**`bytea` 안이 JWT 문자열 그대로다.** 암호화가 없으므로 **교체할 key 도 없다.**
> Q3 는 *"Access Token 과 Refresh Token 을 Redis 에 저장할 경우 Token 을 어떤
> 방식으로 암호화할지"* 를 미지수로 뒀다. **먼저 확인해야 할 것은 "지금은
> 암호화가 없다" 이고, 그것을 B-2 에서 측정했다.**
암호화를 넣는다면 회전 설계는 ②에서 본 모양을 따라야 한다 —
**읽기는 여러 key 를 받아들이고, 쓰기는 한 key 만 쓴다.**
---
## 2. ② 서명 key 회전을 실측한다
### 회전 전
```
발급 토큰의 kid: OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM
JWKS 의 RS256 키 수: 1
/api/me HTTP 200
```
### 회전 — 우선순위가 높은 공급자를 추가한다
```bash
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"]'
```
**Keycloak 의 키 회전은 "바꾸기"가 아니라 "더 높은 우선순위로 추가하기"다.**
```
RS256 키 수: 1 → 2
kid 목록:
1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84 ← 새 키 (우선순위 200)
OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM ← 옛 키 (우선순위 100, 남아 있다)
새 토큰의 kid: 1B4AQHoxZvFaQi1tc1byz8ifU-nYFB6engD4YB4Fz84
```
### 결과 — 무중단이다
```
옛 토큰 /api/me HTTP 200
새 토큰 /api/me HTTP 200
```
**둘 다 통한다.** 새 토큰은 새 키로 서명되고, 옛 토큰은 **JWKS 에 아직 있는
옛 키로 검증**된다.
---
## 3. 옛 키를 제거하면 — 여기가 진짜 질문이다
```bash
kubectl -n keycloak-lab exec keycloak-0 -- /opt/keycloak/bin/kcadm.sh \
delete components/980ee9b7-... -r keycloak-patterns
```
```
RS256 키 수: 2 → 1
옛 토큰 /api/me HTTP 401 ← ★ 즉시 깨진다
새 토큰 /api/me HTTP 200
```
**리소스 서버를 재시작해 JWKS 캐시를 비운 뒤에도 같다.**
```
옛 토큰 /api/me HTTP 401
새 토큰 /api/me HTTP 200
```
### 캐시가 구해주지 않는다
처음에는 "리소스 서버가 JWKS 를 캐시하고 있으니 한동안은 통하겠지" 라고
예상했다. **아니었다. 제거 직후 바로 401 이다.**
이유는 Spring 의 `NimbusJwtDecoder` 동작에 있다 —
**모르는 `kid` 를 만나면 JWKS 를 다시 가져온다.** 그래서 캐시가 오히려
빨리 갱신되고, 결과적으로 **제거가 즉시 반영된다.**
> **캐시를 유예 기간으로 기대하면 안 된다.**
> 유예는 **옛 키를 JWKS 에 남겨두는 기간**으로 만들어야 한다.
---
## 4. 개념
### `kid` — key ID
```json
{"alg":"RS256","typ":"JWT","kid":"OY-caYDNGoP4HMAz-Q9UPTU-DM1i896NuzUZu6gfCqM"}
```
토큰 헤더에 **어느 키로 서명했는지**가 적혀 있다. 검증하는 쪽은 JWKS 에서
`kid` 를 찾아 공개키를 얻는다.
**`kid` 가 있기 때문에 여러 키를 동시에 운용할 수 있다.**
### 안전한 회전의 모양
```
t0 키 A 만 있다. 발급: A, 검증: A
t1 키 B 추가. 발급: B, 검증: A + B ← 겹치는 구간
t2 키 A 제거. 발급: B, 검증: B
★ t1 ~ t2 사이가 "A 로 서명된 것이 살아 있는 기간" 보다 길어야 한다
```
**겹치는 구간의 최소 길이 = 옛 키로 서명된 것 중 가장 오래 사는 것의 수명.**
| 이 실험대에서 | |
|---|---|
| access token | 60초 |
| refresh token | 1800초 (30분) |
| **필요한 겹침** | **최소 30분** |
**Q3 가 물은 "교체하는 동안" 이 바로 이 구간**이고, 길이를 정하는 것은
key 가 아니라 **그 key 로 만든 것의 수명**이다.
### ①에 적용하면
토큰 저장소를 암호화한다면 같은 구조가 필요하다.
```
쓰기: 새 key 하나로만
읽기: 새 key + 옛 key(들) ← key 에도 식별자가 필요하다
제거: 옛 key 로 암호화된 마지막 항목이 만료된 뒤
```
**저장된 값에 `kid` 에 해당하는 표시가 없으면 회전이 불가능하다.**
암호화를 설계할 때 **key 식별자를 값과 함께 저장**해야 하는 이유다.
---
## 5. 겪은 문제
### `kcadm get components` 가 조용히 빈 결과
```bash
kcadm.sh get components -r keycloak-patterns -q type=org.keycloak.keys.KeyProvider
```
`-q type=...` 로는 아무것도 안 나왔다. `--fields` 를 붙여 전체를 받아야 보였다.
**필터가 안 먹었는데 오류도 없었다** — A층에서 반복해 만난 "조용한 실패"다.
---
## 6. 재현 절차 (명령어)
```bash
# 1. 현재 키와 토큰의 kid
kcadm.sh get keys -r keycloak-patterns
curl -s $KC/protocol/openid-connect/certs | tr ',' '\n' | grep kid
# 2. 회전 — 더 높은 우선순위로 추가한다 (바꾸는 것이 아니다)
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"]'
# 3. 옛 토큰이 아직 통하는지 (통해야 정상)
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $OLD" http://echo...:8081/api/me
# 4. 옛 공급자 목록 — -q 필터는 안 먹는다. --fields 로 전체를 본다
kcadm.sh get components -r keycloak-patterns --fields id,name,providerId
# 5. 제거하고 다시 확인 — 여기서 401 이 나와야 정상이다
kcadm.sh delete components/<old-id> -r keycloak-patterns
```
---
## 7. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| **B-7** oauth2-proxy cookie secret | **같은 모양의 문제.** 다만 거기는 겹침 구간을 만들 수 있는가가 관건 |
| **D-2** 버전 업그레이드 | Redis 의 **Java 직렬화 세션**(B-1)도 같은 "옛 형식을 읽을 수 있는가" 문제다 |
| 설계 | 암호화를 넣는다면 **값과 함께 key 식별자를 저장**해야 회전할 수 있다 |
@@ -0,0 +1,250 @@
# B-7 — oauth2-proxy 의 cookie secret 을 교체하면 → Q1 미지수 7
브랜치 `feature/keycloak-b7-cookie-secret-rotation` ·
증거 [`docs/evidence/b7-cookie-secret/`](evidence/b7-cookie-secret/) ·
2026-09-04 16:0016:15 KST
선행: [`B-6`](experiment-b6-key-rotation.md) — 같은 "key 회전" 주제의 다른 사례
**대응 질문** — Q1 미지수 7
> *"OAuth2-Proxy 구조의 replica 들이 같은 cookie secret 을 어떻게 공유하고
> 교체하게 되는가. 교체하는 동안 로그인해 있던 사람은 어떻게 되는가."*
---
## 0. 결론부터
| 물음 | 답 |
|---|---|
| replica 가 secret 을 어떻게 공유하는가 | **같은 k8s Secret 을 읽는다.** 그러면 **콜백이 다른 replica 로 가도 된다** |
| 겹침 구간을 만들 수 있는가 | **★ 없다.** `--cookie-secret` 은 **단수**다 |
| 교체하면 로그인한 사람은 | **쿠키가 무효가 된다.** SSO 가 살아 있으면 재로그인이 조용히 일어난다 |
| **서버 쪽 세션은** | **★ 고아로 남는다.** 티켓을 못 풀어 지우지도 못한다 |
---
## 1. BFF 와 정반대의 성질
B-0 에서 **BFF 는 replica 2개면 로그인 자체가 실패**했다. 인가 요청이
인스턴스 메모리에 있어 콜백이 다른 인스턴스로 가면 못 찾기 때문이다.
oauth2-proxy 는 그렇지 않았다.
```
--- replica 8p5hl ---
[oauthproxy.go:1024] No valid authentication in request. Initiating login.
GET "/api/echo" ← 흐름을 시작한 replica
--- replica b9928 ---
[AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...}
GET "/oauth2/callback?state=..." ← 콜백을 받은 replica
```
**시작한 replica 와 콜백을 처리한 replica 가 다른데도 성공했다.**
| 왜 | |
|---|---|
| BFF | 인가 요청을 **HttpSession**(인스턴스 메모리)에 둔다 |
| oauth2-proxy | 인가 요청(state, CSRF)을 **쿠키**에 두고 **secret 으로 서명**한다 |
**secret 만 같으면 어느 replica 든 그 쿠키를 검증할 수 있다.**
이것이 Q1 이 물은 "어떻게 공유하는가" 의 답이다 — **공유할 상태가 없고,
공유할 것은 secret 하나뿐이다.**
---
## 2. 문제 — 큰 쿠키가 프록시를 넘지 못했다
처음 구성에서 콜백이 계속 **502** 였다.
```
GET /oauth2/callback?state=...&code=... → 502 Bad Gateway
```
**계층을 분리해 원인을 좁혔다.**
```bash
# Traefik 직접 (nginx 우회)
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/ping → 200
curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/ → 302
```
**Traefik 은 정상이고 nginx 가 502 를 낸다.** 그리고 502 는 **쿠키를 설정하는
응답에서만** 났다.
**진단** — oauth2-proxy 는 기본적으로 **세션 전체를 쿠키에 담는다.**
`Set-Cookie` 가 nginx 의 `proxy_buffer_size` 를 넘겼다.
> **B-4 에서 본 헤더 크기 절벽이 이번에는 응답 쪽에서 나타났다.**
> 거기서는 요청 헤더가 8KB 에서 400 이 됐고, 여기서는 응답 헤더가
> 프록시 버퍼를 넘겨 502 가 됐다. **같은 종류의 한계다.**
### 그리고 여기서 환경 사실 하나를 발견했다
nginx 설정을 보려는 시도가 계속 **빈 결과**였다.
```
$ sudo -n true
sudo: a password is required
```
**test-server 의 sudo 는 비밀번호를 요구한다.** 게스트(kc-lab-1/2)는 무암호라
A층에서 `conntrack`·`tc` 를 문제없이 썼는데, **호스트는 다르다.**
> **앞선 "nginx 로그가 비어 있다" 는 관측은 로그가 없던 것이 아니라
> sudo 가 조용히 실패한 것이었다.** A층 내내 만난 "조용한 실패" 유형이
> 도구 자체에서 또 나왔다.
### 해결 — 세션을 Redis 로 옮긴다
```yaml
- --session-store-type=redis
- --redis-connection-url=redis://redis.keycloak-lab.svc:6379
```
```
쿠키: _oauth2_proxy=djIuWDI5aGRYUm9NbDl3Y205NGVTMWlNall4TVRGbVltUXhabVJoWWpO...|1788500470|iPSRUlwHDB0XgC6sUdU4dq1EHq9WQDPYrDoezajKVUA=
└─ 세션 전체가 아니라 티켓이다 (약 180자)
```
**쿠키가 티켓으로 줄자 502 가 사라졌다.**
```
Redis: _oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a
```
---
## 3. 동작 확인 — upstream 이 받는 것
![oauth2-proxy 로그인 성공](evidence/b7-cookie-secret/b7-oauth2proxy-login-success.png)
```json
"x-forwarded-email" : [ "labuser@example.com" ],
"x-forwarded-preferred-username" : [ "labuser" ],
"x-forwarded-user" : [ "27df5ea9-8703-4ec5-badd-d972c583e1ff" ],
"x-forwarded-proto" : [ "https" ]
```
**B-4 에서 위조가 통한다고 측정한 바로 그 헤더**를 oauth2-proxy 가 붙인다.
Forward-Auth 구조의 신원 전달 방식이며, **B-4 의 결론이 그대로 적용된다**
edge 가 붙인 것과 공격자가 보낸 것을 upstream 은 구별하지 못한다.
---
## 4. ★ secret 교체 — 겹침 구간이 없다
```bash
kubectl -n keycloak-lab exec deploy/oauth2-proxy -- /bin/oauth2-proxy --help | grep cookie-secret
```
```
--cookie-secret string the seed string for secure cookies (optionally base64 encoded)
```
**단수다.** `--cookie-secrets``--old-cookie-secret` 도 없다.
> **B-6 에서 Keycloak 이 두 키를 동시에 들고 무중단 회전을 할 수 있었던 것과
> 대비된다.** 거기서는 `kid` 로 여러 키를 구분했는데, **oauth2-proxy 의 쿠키에는
> 그런 식별자가 없다.**
>
> **key 식별자가 없으면 회전에 겹침 구간을 만들 수 없다** — B-6 에서
> "값과 함께 key 식별자를 저장해야 한다" 고 쓴 것의 반례다.
### 교체하고 관찰했다
```bash
kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
-p '[{"op":"replace","path":"/spec/.../secretKeyRef/key","value":"COOKIE_SECRET_B"}]'
```
```
[stored_session.go:94] Error loading cookied session:
session ticket cookie failed validation: <nil>, removing session
[stored_session.go:97] Error removing session:
error decoding ticket to clear session: session ticket cookie failed validation
[oauthproxy.go:1024] No valid authentication in request. Initiating login.
...
[AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ...}
```
| 관찰 | |
|---|---|
| 옛 쿠키 | **검증 실패**`session ticket cookie failed validation` |
| 사용자 경험 | **Keycloak SSO 가 살아 있어 조용히 재로그인**됐다. 로그인 화면을 안 봤다 |
| **서버 쪽 세션** | **★ 지우지 못했다** |
### 고아 세션이 남는다
```
_oauth2_proxy-978dfaefbdadccb96c7be1625dba5616 ← 새 세션
_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a ← ★ 옛 세션. 남아 있다
총: 2 개
```
**`Error removing session: error decoding ticket to clear session`**
정리하려면 **티켓에서 Redis 키를 계산해야 하는데, 그 티켓을 못 푼다.**
그래서 **지울 수도 없다.**
```
secret 교체
└─ 옛 티켓을 못 푼다
├─ 사용자는 재로그인 (SSO 가 있으면 조용히)
└─ ★ 서버 세션은 TTL 만료까지 고아로 남는다
```
**로그인한 사용자 수만큼 고아가 생긴다.** TTL(여기서는 1시간)이 지나야 사라진다.
---
## 5. Q1 미지수 7 에 대한 답
| 물음 | 답 |
|---|---|
| **어떻게 공유하는가** | 같은 k8s Secret 을 모든 replica 가 읽는다. 상태를 공유할 필요가 없다 |
| **어떻게 교체하는가** | **무중단 교체 수단이 없다.** 옵션이 단수이므로 한 번에 바뀐다 |
| **교체 중 로그인한 사람은** | 쿠키가 무효가 된다. **IdP SSO 가 살아 있으면 눈에 안 띄고, 없으면 전원 로그인 화면** |
| (추가로 드러난 것) | **서버 세션이 고아로 남는다** |
### 그래서 무엇을 해야 하는가
| | |
|---|---|
| 교체 시점 | **트래픽이 적은 시간.** 전원이 한 번씩 재인증을 거친다 |
| IdP SSO 유지 | SSO 세션이 살아 있으면 **사용자에게 안 보인다** — 이것이 실질적 완충재다 |
| 고아 정리 | TTL 에 의존하거나 **교체 전에 Redis 를 비운다** (어차피 다 무효다) |
| 근본 | 쿠키에 **key 식별자**가 있어야 겹침이 가능하다 — 지금 구현엔 없다 |
---
## 6. 재현 절차 (명령어)
```bash
# 1. 배포 (Redis 세션 저장소로 — 쿠키가 크면 프록시에서 502)
kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml
# 2. 502 가 나면 계층을 분리한다
curl -H "Host: app2.hyeonworks.com" http://<node-ip>/ping # Traefik 직접
# 3. 겹침 가능 여부 — 옵션이 단수인지 확인
kubectl -n keycloak-lab exec deploy/oauth2-proxy -- /bin/oauth2-proxy --help | grep cookie-secret
# 4. 교체
kubectl -n keycloak-lab patch deployment oauth2-proxy --type=json \
-p '[{"op":"replace","path":"/spec/template/spec/containers/0/env/1/valueFrom/secretKeyRef/key","value":"COOKIE_SECRET_B"}]'
# 5. 무슨 일이 났는지 — 로그와 Redis 를 함께 본다
kubectl -n keycloak-lab logs deploy/oauth2-proxy | grep -i "stored_session"
kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '_oauth2_proxy*'
```
---
## 7. 다음 실험에 남기는 것
| 실험 | 이 실험이 준 것 |
|---|---|
| **C-1** SSO | **app1(BFF) 과 app2(oauth2-proxy) 두 앱이 준비됐다.** 서로 다른 구조로 같은 IdP 를 쓴다 |
| **D-3** 비밀 관리 | cookie secret 이 k8s Secret 에 평문이다 |
| 운영 | secret 교체는 **무중단이 아니다.** 창을 고르고 고아를 정리한다 |