--- id: d53181b9-bb28-4703-97ed-98adfb5b18dc kind: SETUP slug: reproduce-b7-cookie-secret-rotation title: cookie secret 을 갈아치우고 로그인해 있던 세션이 어떻게 되는지 본다 topic: trust-handed-over-at-the-edge topicName: 위조 신원 헤더와 로그아웃 전파 project: keycloak-session-store status: 게시 전 studio: "https://hyeonworks.com/studio/documents/d53181b9-bb28-4703-97ed-98adfb5b18dc/edit" pinnedVersions: - name: Redis version: 7.4.x - name: curl version: 8.5.0 source: - final/document.md#b층-재현-절차-아홉-편을-직접-치는-순서-b-7 sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af --- # cookie secret 을 갈아치우고 로그인해 있던 세션이 어떻게 되는지 본다 oauth2-proxy 의 cookie secret 을 A 에서 B 로 바꿨을 때 로그인해 있던 세션이 어떻게 되는지 보는 절차다. Grafana 의 Ingress 를 빌려 띄우고 결과를 로그로 가른다. 남의 도메인을 빌리므로 끝나면 반드시 돌려준다. 약 20분. ## 관계 - **쿠키에 세션을 담으면 지울 대상을 잃는다 — TTL 로 되찾은 고아 세션** 이 절차가 만드는 고아 세션을 그 기록이 결론으로 적는다. 여기서는 그것이 생기는 데까지만 친다. - **nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않았다** 로그인 뒤 업스트림이 받는 `x-forwarded-*` 네 줄을 그 기록은 밖에서 위조해 보내 통과시켰다. - **TTL 로 고아 세션을 골라내 지운다** 같은 배포를 이어 쓰지만 치는 것이 다르다. 여기는 회전을 A → B 로 한 번만 치고 고아가 생기는 데까지 보며, 그쪽은 1차와 2차를 자기 손으로 치고 기동 로그의 `refresh:disabled` 를 맨 앞에서 확인한 뒤 TTL 을 역산해 고아를 골라 지운다. 그 확인이 깨진 환경에서는 그쪽 절차를 쓰면 안 된다. 되돌리기도 다르다 — 여기는 secret 참조와 Grafana Ingress 를 되돌리고, 그쪽은 지운 세션이 돌아오지 않는다. - **서명 키를 더한 뒤 옛 키를 지우고 옛 토큰이 언제 끊기는지 본다** 같은 회전을 식별자가 있는 쪽에서 치는 편이다. 거기서는 토큰 헤더의 `kid` 가 겹치는 구간을 만들어 줬고, 여기서는 그 식별자가 없어 겹칠 수단 자체가 없다. - **아무 저장소도 주지 않고 Spring 이 무엇을 고르는지 찍어서 확인한다** 먼저 해 둬야 하는 편이다. 이 절차가 세션을 넣는 Redis 를 거기서 띄운다. ## 본문 ## 읽기 전에 — 어디서 치는가 기계가 둘이고 표시가 둘이다. `kubectl` 과 `redis-cli` 는 `[kc-lab-1]` 에서 치고, 앞단 nginx 를 건너뛰고 Traefik 을 직접 두드리는 `curl` 과 `sudo` 는 `[test-server]` 에서 친다. 앞단 nginx 가 그 호스트에 있고 `192.168.122.11:80` 으로 넘겨주므로, 호스트에서 그 주소를 바로 치면 nginx 를 건너뛴다. 브라우저도 필요하다. 쿠키가 `HttpOnly` 이고 OIDC(OpenID Connect, OAuth2 위에 신원 확인을 얹은 규격) 흐름을 폼까지 걸어야 세션이 생긴다. `curl` 로 완주하려던 시도는 실패했다. | 무엇 | 값 | |---|---| | 네임스페이스 | `keycloak-lab` · Grafana 는 `observability` | | 빌리는 이름 | `app2.hyeonworks.com` — 평소 Grafana 로 간다 | | 주입 수단 | `patch deployment` 로 `secretKeyRef.key` 를 `COOKIE_SECRET_A` 에서 `COOKIE_SECRET_B` 로 바꾼다 | | 세션 저장소 | Redis. `redis.keycloak-lab.svc:6379` | | replica | oauth2-proxy 파드 둘. 서로 다른 노드 | | 시각 표시 | oauth2-proxy 로그는 UTC, `kubectl` 출력은 KST. 회전 시각을 UTC 로 적는다 | | 전 구간 | 약 20분 | | 도구 | `jq` 도 `yamllint` 도 이 실험대에 없다 | ## 이 실험이 가르는 것 앞선 작업이 남긴 열린 질문 Q1 의 미지수 7 이 이렇게 물었다. > *"OAuth2-Proxy 구조의 replica 들이 같은 cookie secret 을 어떻게 공유하고 교체하게 되는가. > 교체하는 동안 로그인해 있던 사람은 어떻게 되는가."* B-6 에서 Keycloak 은 두 키를 동시에 들고 무중단으로 회전했다. 토큰 헤더에 `kid` 가 있어서 읽기는 여러 키, 쓰기는 하나가 됐기 때문이다. | 무엇을 기대했나 | 무엇이 나왔나 | |---|---| | B-6 의 모양대로라면 oauth2-proxy 도 겹치는 구간을 만들 수 있을 것 | `--cookie-secret` 은 단수이고 쿠키에 키 식별자가 없다 | 예측하지 않았던 것이 하나 더 나온다. 사용자는 아무것도 못 느끼는데 서버 쪽에 지워지지 않는 세션이 생긴다. 그 「지우지 못한다」를 이어서 재는 것이 B-7a 이고, 이 절차는 거기까지 가지 않는다. 두 구조는 인가 요청을 어디에 두는지가 다르다. ```text BFF 인가 요청을 서버 메모리(HttpSession)에 둔다 → replica 를 넘으면 실패 oauth2-proxy 인가 요청을 쿠키에 두고 secret 으로 봉인한다 → replica 를 넘어도 성공 대신 secret 이 단일 지점 ``` 절차를 끝까지 밟으면 `--cookie-secret` 이 단수라는 도움말 한 줄, 회전만으로는 Redis 가 그대로인 것, 로그인 화면이 안 뜨는데 로그에는 재인증이 찍혀 있는 것, 세션 키가 하나에서 둘로 늘어난 것, 흐름을 시작한 파드와 콜백을 받은 파드가 다른데도 성공한 것을 자기 화면에서 보게 된다. ## 전제와 되돌리기 - `05-keycloak` 이 끝나 있고 realm `keycloak-patterns` 에 클라이언트 `oauth2-proxy` 와 사용자 `labuser`(비밀번호 `labpass`)가 있다. - B-0 이 끝나 있어야 한다. Redis 를 거기서 띄우고 `redis.keycloak-lab.svc:6379` 로 떠 있다. - 브라우저가 있어야 한다. **이건 남의 도메인을 빌리고 남의 세션을 끊는 실험이다.** 둘을 건드린다. 인증서가 `auth` · `app1` · `app2` 세 이름만 덮어서 네 번째 이름을 못 만들기 때문에 Grafana 의 Ingress 를 잠시 내리고 `app2` 를 빌린다. 그리고 secret 을 바꾸면 그때 로그인해 있던 사람의 쿠키가 전부 무효가 된다. 되돌리기는 둘이고 먼저 읽어 둔다. ```bash label="[kc-lab-1] ① secret 참조를 A 로 되돌린다" 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_A"}]' ``` ①의 `env/1` 은 매니페스트의 env 배열 순서에 달린 숫자다. 중간에 멈춰서 되돌리는 사람은 주입 2 절 ①의 `env[*].name` 을 먼저 쳐서 `OAUTH2_PROXY_COOKIE_SECRET` 이 0 부터 세어 몇 번째인지 보고, 두 번째가 아니면 위 경로의 `1` 을 그 숫자로 바꾼다. 숫자가 틀리면 클라이언트 비밀을 쿠키 secret 으로 덮어쓴다. ```bash label="[kc-lab-1] ② 빌린 Ingress 를 걷고 Grafana 것을 올린다" kubectl -n keycloak-lab delete ingress oauth2-proxy kubectl apply -f ~/grafana-ingress-backup.yaml ``` ②가 올리는 백업 파일은 아래 주입 전 1 절이 만든다. B-4 로 app2 를 먼저 빌린 적이 있으면 그 편은 같은 백업을 `/tmp/grafana-ingress-backup.yaml` 에 떠 두므로, `~` 쪽이 없을 때 그쪽을 본다. ## 주입 전에 같은 명령으로 먼저 본다 ```text Ingress 백업 → 배포 → replica 배치 → secret 키 이름 → 로그인 → Redis → 쿠키 모양 ``` ### 1. 지금 app2 가 무엇인지 보고 Grafana Ingress 를 백업한다 **목적** — 실험이 끝났을 때 되돌릴 파일을 만든다. **행동** — 먼저 지금 상태를 보고, 백업을 뜨고, 그 백업이 비어 있지 않은지 확인한다. ```bash label="[test-server] ① 지금 app2 가 어디로 가는지 본다" curl -sI https://app2.hyeonworks.com/ | head -3 ``` **②를 치기 전에 읽는다.** 셸은 `>` 를 kubectl 보다 먼저 처리한다. `~/grafana-ingress-backup.yaml` 은 kubectl 이 돌기도 전에 0바이트가 되고, `get` 이 실패하면 앞서 떠 둔 백업이 그때 없어진다. 뒤따르는 `wc -l` 과 `grep -c` 는 이미 비어 버린 파일을 센다. 그래서 이 절을 두 번째로 치는 사람은 — B-4 로 app2 를 먼저 빌렸거나 실험을 중간에 다시 시작했다면 — `wc -l ~/grafana-ingress-backup.yaml` 을 먼저 쳐서 쓸 만한 백업을 이미 갖고 있는지 보고, 갖고 있으면 ②를 건너뛴다. ```bash label="[kc-lab-1] ② 백업을 뜨고 내용이 있는지 센다" kubectl -n observability get ingress grafana -o yaml > ~/grafana-ingress-backup.yaml wc -l ~/grafana-ingress-backup.yaml grep -c 'app2.hyeonworks.com' ~/grafana-ingress-backup.yaml ``` **예상 결과** — ① 은 Grafana 로 가고 있으면 `302` 로 `/login` 을 가리킨다. ② 는 줄 수가 0 이 아니고 `app2.hyeonworks.com` 이 1회 이상 잡힌다. **왜 필요한가** — 백업 파일이 빈 채로 원본을 지우면 복구할 것이 없다. `wc -l` 과 `grep -c` 가 그 사고를 여기서 막는다. **문제가 생기면** — 둘 중 하나라도 `0` 이면 그대로 진행하지 않는다. 네임스페이스와 Ingress 이름을 다시 본다. Grafana Ingress 자체가 없다고 나오면 B-4 가 먼저 app2 를 빌려 갔다. 그 편은 같은 백업을 `/tmp/grafana-ingress-backup.yaml` 에 떠 두므로 그쪽을 본다. 양쪽 다 비어 있으면 여기서 멈춘다 — 지금은 떠 둘 원본이 없고, 지운 Ingress 를 되살리는 절차는 가이드에 없다(unknown). ### 2. Grafana Ingress 를 내리고 oauth2-proxy 를 배포한다 **목적** — `app2.hyeonworks.com` 을 oauth2-proxy 쪽으로 돌린다. **행동** — 내리고, 올리고, 롤아웃이 끝날 때까지 기다린다. ②의 `deploy/lab/k8s/b7-oauth2-proxy.yaml` 은 저장소 체크아웃의 루트에서 푸는 상대 경로다. 체크아웃을 어디에 뒀는지는 가이드에 없으므로(unknown), 그 경로가 풀리는 디렉터리로 옮긴 다음 ②를 친다. ```bash label="[kc-lab-1] ① Grafana Ingress 를 내린다" kubectl -n observability delete ingress grafana ``` ```bash label="[kc-lab-1] ② oauth2-proxy 를 배포한다" kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s ``` **예상 결과** — 실측은 이렇다(observed, `01-deploy.txt`). ```text === Grafana ingress 를 잠시 내린다 (app2 를 빌린다) === grafana ingress 삭제 ``` ```text 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 ``` **왜 필요한가** — 같은 이름을 두 Ingress 가 주장하면 어느 쪽으로 갈지가 컨트롤러 판단에 맡겨진다. 내리는 것이 먼저다. **문제가 생기면** — 매니페스트를 못 찾는다고 끝나면 체크아웃 루트가 아닌 디렉터리에서 쳤다. 롤아웃이 타임아웃이면 `kubectl -n keycloak-lab get pods -l app=oauth2-proxy` 로 파드 상태부터 본다. ### 3. replica 가 둘인지, 어느 노드에 있는지 본다 **무엇을 보는가** — 파드 수와 배치. ```bash label="[kc-lab-1] 파드 배치를 본다" kubectl -n keycloak-lab get pods -l app=oauth2-proxy -o wide ``` **어디를 보나** — 실측은 이렇다(observed, `01-deploy.txt`). ```text oauth2-proxy-c76b49c59-8p5hl true kc-lab-1 oauth2-proxy-c76b49c59-b9928 true kc-lab-2 ``` **이 값이 뜻하는 것** — 파드 두 개가 서로 다른 노드에 있다. 파드 이름의 끝 다섯 글자를 적어 둔다. 관찰 절에서 어느 replica 가 무엇을 했는지 그 글자로 가른다. replica 가 하나면 「공유」라는 말이 성립하지 않는다. ### 4. 진입점 두 곳이 갈라지는지 본다 **무엇을 보는가** — 인증을 거치는 경로와 안 거치는 경로. ```bash label="[test-server] 두 경로의 상태 코드를 뽑는다" curl -s -o /dev/null -w '/ %{http_code}\n' https://app2.hyeonworks.com/ curl -s -o /dev/null -w '/ping %{http_code}\n' https://app2.hyeonworks.com/ping ``` **어디를 보나** — 실측은 이렇다(observed, `01-deploy.txt`). ```text === 진입점 확인 === https://app2.hyeonworks.com/ HTTP 302 /ping HTTP 200 ``` | 경로 | 정상 | 뜻 | |---|---|---| | `/` | `302` | 인증이 없으니 Keycloak 으로 보낸다 — 프록시가 일하고 있다 | | `/ping` | `200` | 인증을 거치지 않는 헬스 경로 — 프록시 자체는 살아 있다 | **이 값이 뜻하는 것** — `/ping` 도 안 되면 프록시가 안 떴고, `/ping` 만 되면 프록시는 떴는데 앞단이 무언가를 막고 있다. ### 5. 502 를 만나면 한 겹씩 벗겨 좁힌다 **무엇을 보는가** — 502 를 낸 것이 앞단 nginx 인지, 그 뒤 Traefik 인지, 파드인지. 원래 구성에서 콜백이 계속 502 였다. ```bash label="[test-server] 앞단 nginx 를 건너뛰고 Traefik 에 바로 친다" curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/ping curl -H "Host: app2.hyeonworks.com" http://192.168.122.11/ ``` **어디를 보나** — 실측은 이렇다(observed). ```text 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 직접은 정상이므로 502 를 내는 것은 그 앞의 nginx 이고, 502 는 쿠키를 설정하는 응답에서만 났다. oauth2-proxy 는 기본적으로 세션 전체를 쿠키에 담는데 그 `Set-Cookie` 가 nginx 의 `proxy_buffer_size` 를 넘겼다. B-4 에서 본 헤더 크기 절벽이 이번에는 응답 쪽에서 나타났다 — 거기서는 요청 헤더가 8KB 에서 400 이 됐고, 여기서는 응답 헤더가 프록시 버퍼를 넘겨 502 가 됐다. 해결은 세션을 Redis 로 옮기는 것이고 매니페스트에 이미 들어 있다. ```bash label="[kc-lab-1] 세션 저장소가 Redis 인지 인자에서 본다" kubectl -n keycloak-lab get deploy oauth2-proxy \ -o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep -i session ``` 모양은 이렇다(모양은 observed). ```text "--session-store-type=redis" "--redis-connection-url=redis://redis.keycloak-lab.svc:6379" ``` nginx 설정을 직접 보려던 시도는 계속 빈 결과였다. 호스트에서 무언가가 빈 결과를 주면 먼저 이것을 친다. ```bash label="[test-server] 빈 결과의 원인이 권한인지부터 본다" sudo -n true ``` 실측은 이렇다(observed). ```text $ sudo -n true sudo: a password is required ``` `test-server` 의 sudo 는 비밀번호를 요구한다. 게스트(`kc-lab-1` 과 `kc-lab-2`)는 무암호라 A층에서 `conntrack` 과 `tc` 를 문제없이 썼는데 호스트는 다르다. 앞선 「nginx 로그가 비어 있다」는 관측은 로그가 없던 것이 아니라 sudo 가 조용히 실패한 것이었다. ### 6. secret 의 키 이름과 길이를 본다 **무엇을 보는가** — 회전 대상이 준비되어 있는지. 값은 찍지 않는다. ```bash label="[kc-lab-1] ① Secret 의 키 이름만 뽑는다" kubectl -n keycloak-lab get secret oauth2-proxy-secrets \ -o jsonpath='{.data}' | tr ',' '\n' | grep -o '"[A-Z_]*"' ``` 모양은 이렇다(모양은 observed). ```text "CLIENT_SECRET" "COOKIE_SECRET_A" "COOKIE_SECRET_B" ``` 길이도 본다. 가이드가 아래 두 줄을 미검증으로 표시했다(unknown) — 원래 실행 기록에 이 명령의 출력이 없다. ```bash label="[kc-lab-1] ② 두 cookie secret 의 길이만 센다 (unknown)" kubectl -n keycloak-lab get secret oauth2-proxy-secrets \ -o jsonpath='{.data.COOKIE_SECRET_A}' | base64 -d | wc -c kubectl -n keycloak-lab get secret oauth2-proxy-secrets \ -o jsonpath='{.data.COOKIE_SECRET_B}' | base64 -d | wc -c ``` **어디를 보나** — oauth2-proxy 는 정확히 16 · 24 · 32 바이트만 받는다. 매니페스트의 값은 32바이트짜리이고, 다른 수가 나오면 프록시가 기동에서 죽는다. **이 값이 뜻하는 것** — 회전 대상이 미리 두 개 준비되어 있고, 그래서 이 실험이 한 번 바꾸고 되돌릴 수 있는 형태가 된다. 16 · 24 · 32 라는 제약은 oauth2-proxy 의 것이지 이 실험대가 잰 값이 아니다. ### 7. 브라우저로 로그인하고 업스트림이 받는 헤더를 본다 **무엇을 보는가** — 세션이 생겼는지, 그리고 프록시가 업스트림에 무엇을 붙이는지. 브라우저에서 `https://app2.hyeonworks.com/api/echo` 를 열고 `labuser` / `labpass` 로 로그인한다. Keycloak 로그인 화면이 뜨고, 통과하면 업스트림(echo)의 JSON 이 보인다. **어디를 보나** — 실측은 이렇다(observed, `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 의 결론이 그대로 적용된다 — 엣지가 붙인 것과 공격자가 보낸 것을 업스트림은 구별하지 못한다. ### 8. 세션이 Redis 에 들어갔는지, 쿠키가 티켓인지 본다 **무엇을 보는가** — 한 번은 통째로 본 다음 접두사로 좁힌다. ```bash label="[kc-lab-1] ① Redis 전체를 본다" kubectl -n keycloak-lab exec deploy/redis -- redis-cli --scan --pattern '*' kubectl -n keycloak-lab exec deploy/redis -- redis-cli dbsize ``` **어디를 보나** — 실측은 이렇다(observed, `03-rotation.txt`). ```text === 세션이 Redis 에 들어갔는가 === b5:pvc _oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a dbsize: 2 ``` `dbsize` 는 2 인데 세션은 하나다. `b5:pvc` 는 B-5 가 남긴 키이고 이 실험과 무관하다. 이 Redis 는 이 실험 전용이 아니므로 `dbsize` 로 세션을 세면 틀린다. ```bash label="[kc-lab-1] ② 세션만 세려면 접두사로 좁힌다" kubectl -n keycloak-lab exec deploy/redis -- \ redis-cli --scan --pattern '_oauth2_proxy-*' ``` `KEYS` 대신 `--scan` 을 쓴다. `KEYS` 는 Redis 를 블로킹하고, 실험대에서는 티가 안 나지만 습관을 여기서 들인다. **이 값이 뜻하는 것** — 마지막으로 쿠키가 티켓인지 확인한다. 세션 저장소를 Redis 로 옮기면 쿠키에는 세션 전체가 아니라 티켓만 담긴다. 브라우저 개발자 도구에서 Application 또는 저장소 → Cookies → `_oauth2_proxy` 로 본다. 값은 지금 쓸 수 있는 세션 자격증명이라 모양과 길이만 적는다(observed). ```text _oauth2_proxy=|| └─ Redis 키를 여기서 계산한다 세션 전체가 아니라 티켓이다 (약 180자) ``` `|` 로 나뉜 세 토막과 전체 길이를 본다. 쿠키가 짧아졌고 그래서 502 가 사라졌다. Redis 키 이름은 이 티켓에서 계산되고, 관찰 절의 「지우지 못한다」가 그 관계에서 나온다. ## 주입 ### 1. 겹칠 수 있는지부터 묻는다 **무엇을 보는가** — 회전을 치기 전에, 애초에 두 secret 을 동시에 들 수 있는지. ```bash label="[kc-lab-1] 도움말에서 cookie-secret 을 찾는다" kubectl -n keycloak-lab exec deploy/oauth2-proxy -- \ /bin/oauth2-proxy --help 2>&1 | grep cookie-secret ``` **어디를 보나** — 실측은 이렇다(observed, `03-rotation.txt`). ```text --cookie-secret string the seed string for secure cookies (optionally base64 encoded) ``` **이 값이 뜻하는 것** — `string` 이고 복수형이 아니다. `--cookie-secrets` 도 `--old-cookie-secret` 도 목록에 없으므로 겹치는 구간을 만들 수단이 아예 없다. B-6 에서 Keycloak 이 두 키를 동시에 들 수 있었던 것은 토큰 헤더에 `kid` 가 있어서였고, oauth2-proxy 의 쿠키에는 그런 식별자가 없다. ```text 식별자 있음 → 읽기는 여러 key, 쓰기는 하나 → 겹침 가능 (B-6) 식별자 없음 → 전부 한 번에 바뀐다 → 겹침 불가 (B-7) ``` 겹칠 수 있는가에는 이 한 줄이 답했고, 남은 절차는 그래서 실제로 무슨 일이 나는지를 본다. ### 2. env 인덱스를 확인하고 secret 참조를 A 에서 B 로 바꾼다 **목적** — Deployment 가 읽는 cookie secret 을 갈아치운다. **행동** — patch 가 지목하는 `env/1` 이 맞는지 먼저 보고, 시각을 남기고, 바꾼다. ```bash label="[kc-lab-1] ① env 배열의 이름 순서를 본다" kubectl -n keycloak-lab get deploy oauth2-proxy \ -o jsonpath='{.spec.template.spec.containers[0].env[*].name}'; echo ``` 모양은 이렇다(모양은 observed). ```text OAUTH2_PROXY_CLIENT_SECRET OAUTH2_PROXY_COOKIE_SECRET ``` `OAUTH2_PROXY_COOKIE_SECRET` 이 몇 번째인지 0부터 센다. 위 모양에서는 두 번째이므로 `env/1` 이고, 순서가 다르면 patch 의 숫자를 고친다. ```bash label="[kc-lab-1] ② 시각을 UTC 로 남기고 참조를 바꾼다" date -u '+%H:%M:%S UTC 회전' 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"}]' kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s ``` **예상 결과** — 실측은 이렇다(observed, `03-rotation.txt`). ```text === ★ secret 을 A → B 로 교체한다 === deployment.apps/oauth2-proxy patched deployment "oauth2-proxy" successfully rolled out ``` **왜 필요한가** — `env/1` 은 매니페스트 순서에 달린 값이라 그대로 믿지 않는다. 틀리면 클라이언트 비밀을 쿠키 secret 으로 덮어쓴다. 그리고 시각을 UTC 로 적어 두는 까닭은 프록시 로그가 UTC 이고 B-7a 의 정리 규칙이 이 값을 기준으로 고아를 고르기 때문이다. **문제가 생기면** — patch 뒤에 프록시가 기동에서 죽으면 인덱스를 잘못 짚었다. `env[*].name` 순서를 다시 본다. ## 주입 검증 ### 1. Deployment 의 참조가 실제로 바뀌었는가 ```bash label="[kc-lab-1] 지금 참조하는 키 이름을 뽑는다" kubectl -n keycloak-lab get deploy oauth2-proxy \ -o jsonpath='{.spec.template.spec.containers[0].env[1].valueFrom.secretKeyRef.key}'; echo ``` 실측은 이렇다(observed, `03-rotation.txt`). ```text 현재 secret 키: COOKIE_SECRET_B ``` 바뀐 것은 Deployment 의 참조이지 Secret 의 내용이 아니다. 두 값 다 그대로 있고 어느 쪽을 읽을지만 바뀌었으며, 그래서 되돌리기가 한 줄이다. ### 2. Redis 는 그대로인가 ```bash label="[kc-lab-1] 주입 전과 똑같은 줄을 친다" kubectl -n keycloak-lab exec deploy/redis -- \ redis-cli --scan --pattern '_oauth2_proxy-*' ``` 실측은 이렇다(observed, `03-rotation.txt`). ```text Redis 세션은 그대로인가: 2 키 ``` **어디를 보나** — 나오는 줄의 개수를 주입 전 8 절 ②에서 본 것과 견준다. 위 실측의 「2 키」는 원래 실행이 적어 낸 숫자인데, 같은 실행이 8 절에서 접두사로 좁혀 본 세션은 `_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a` 하나였고 `b5:pvc` 를 더해야 둘이 된다. 원래 실행이 여기서 무엇을 셌는지는 기록에 없다(unknown). 따라 하는 사람 화면에는 `_oauth2_proxy-` 로 시작하는 줄이 8 절 ②와 같은 수만큼 나온다. 줄이 하나만 나와도 주입은 걸린 상태이고, 판정 기준은 숫자 2 가 아니라 회전 전과 같은 수인가다. 세션 수가 회전 전과 같다. 회전 자체는 아무 일도 일으키지 않으므로 여기서 「실험 실패」라고 결론 내리면 틀린다. 무슨 일이 나려면 누군가 옛 쿠키를 들고 와야 한다. ### 3. 파드가 실제로 새로 떴는가 ```bash label="[kc-lab-1] 파드 이름이 바뀌었는지 본다" kubectl -n keycloak-lab get pods -l app=oauth2-proxy -o wide ``` 파드 이름이 주입 전과 다르다. 같으면 patch 가 아무 필드도 안 바꿨다. 이미 B 였거나 경로가 틀렸다. 그래서 주입 전 3 절에서 적어 둔 끝 다섯 글자는 여기서 쓸모가 없어진다. 이 출력에 나온 새 이름 둘의 끝 다섯 글자를 다시 적어 둔다 — 아래 관찰 절에서 어느 replica 가 흐름을 시작하고 어느 replica 가 콜백을 받았는지 그 글자로 가른다. ## 관찰 로그인했던 그 브라우저 그대로 `https://app2.hyeonworks.com/api/echo` 를 연다. 볼 것은 로그인 화면이 뜨는가다. 실측은 뜨지 않았다(observed). 화면이 잠깐 깜빡이고 그대로 열린다. Keycloak SSO 세션이 살아 있어서 조용히 재인증이 일어났다. 쿠키는 분명히 무효가 됐는데 사용자 눈에는 아무 일도 없었다. **여기서 읽는 방향이 갈린다.** 「로그인 화면이 안 떴으니 교체가 무중단이구나」로 읽으면 정확히 뒤집어 읽는다. 쿠키는 죽었고 사용자는 실제로 재인증을 거쳤다. SSO 가 그 사실을 가려 준 것이고, IdP SSO 가 없거나 만료됐으면 전원이 로그인 화면을 본다. 로그가 무슨 일이 났는지 말한다. 먼저 최근 로그를 통째로 본다. ```bash label="[kc-lab-1] ① 최근 3분을 파드 이름과 함께 본다" kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=3m --prefix ``` `--prefix` 는 각 줄 앞에 파드 이름을 붙여 준다. replica 가 둘이므로 이것이 없으면 누가 무엇을 했는지 못 가린다. `--since=3m` 은 최근 3분만 보므로 브라우저로 접속한 뒤 3분을 넘겨 치면 아무 줄도 안 나온다. 그때 나온 빈 결과는 「로그가 없다」가 아니라 창을 놓쳤다는 뜻이니, 브라우저를 한 번 더 열고 곧바로 친다. 그다음 좁힌다. ```bash label="[kc-lab-1] ② 세션 저장소 쪽 줄만 좁힌다" kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=3m | grep -i stored_session ``` 실측은 이렇다(observed, `03-rotation.txt`). ```text [2026/09/04 05:42:18] [stored_session.go:94] Error loading cookied session: session ticket cookie failed validation: , 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: ``` 두 줄이 다른 말을 하고 있다. | 줄 | 뜻 | |---|---| | `stored_session.go:94` | 쿠키를 열 수 없다 → 세션을 지우겠다 | | `stored_session.go:97` | 그 지우기가 실패했다 → `error decoding ticket to clear session` | 94 만 보고 「정리됐구나」로 읽으면 틀린다. 97 이 진짜 결과다. 이어지는 줄이 사용자 쪽 이야기다(observed). ```text [2026/09/04 05:42:18] [oauthproxy.go:1024] No valid authentication in request. Initiating login. ... [AuthSuccess] Authenticated via OAuth2: Session{email:labuser@example.com ... ``` `Initiating login` 과 `AuthSuccess` 가 같은 초에 있다. 로그인 흐름이 실제로 돌았고 사람 손이 안 들어갔다. 그 두 줄 사이에 화면이 깜빡였다. 그다음 Redis 를 본다. ```bash label="[kc-lab-1] 세션 키를 다시 센다" kubectl -n keycloak-lab exec deploy/redis -- \ redis-cli --scan --pattern '_oauth2_proxy-*' ``` 실측은 이렇다(observed, `03-rotation.txt`). ```text === Redis 세션 수 (옛 세션이 남아 있는가) === _oauth2_proxy-978dfaefbdadccb96c7be1625dba5616 _oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a 총: 2 개 ``` 키가 둘이다. 뒤엣것(`b26111f…`)은 회전 전의 세션이고 앞엣것은 방금 새로 생겼다. 사용자는 하나인데 서버 세션이 둘이다. 옛 것은 아무도 쓸 수 없고 프록시도 지우지 못한다. 못 지우는 까닭은 티켓과 키의 관계에 있다. Redis 세션 저장소를 쓰면 쿠키에는 티켓만 담기고, 티켓은 두 부분이다. ```text 티켓 = <세션 ID>.<암호화 키> │ └─ 값을 복호화할 키 └─ Redis 키 이름을 만든다 → _oauth2_proxy- ``` 티켓 전체가 cookie secret 으로 봉인되어 있다. secret 을 바꾸면 티켓을 열 수 없고, 그러면 세션 ID 조차 못 읽는다. 프록시는 「이 세션은 못 쓴다」까지는 알지만 그 세션이 Redis 어디에 있는지를 모른다. 그래서 `removing session` 을 시도하고 실패한다. ```text secret 교체 └─ 옛 티켓을 못 푼다 ├─ 사용자는 재로그인 (SSO 가 있으면 조용히) └─ ★ 서버 세션은 TTL 만료까지 고아로 남는다 ``` 로그인한 사용자 수만큼 고아가 생긴다. 이 절차는 여기서 멈춘다. 정말 사라지는지, 운영자는 지울 수 있는지, 어느 것이 고아인지는 B-7a 가 이어서 잰다. 덤으로, 로그를 파드별로 갈라 보면 BFF 와 정반대인 성질이 보인다. ```bash label="[kc-lab-1] 흐름을 시작한 파드와 콜백을 받은 파드를 가른다" kubectl -n keycloak-lab logs -l app=oauth2-proxy --since=10m --prefix \ | grep -E 'Initiating login|AuthSuccess' ``` 실측은 해설 문서에 이 모양으로 남아 있다(observed). ```text --- 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 ``` 예시의 `8p5hl` · `b9928` 은 주입 전 3 절이 보여 준 회전 전 파드 이름과 같다. 회전이 파드를 새로 띄웠으므로 따라 하는 사람 화면에는 주입 검증 3 절에서 다시 적어 둔 이름이 나온다. 글자만 다르고 읽는 법은 같다. 시작한 파드와 콜백을 처리한 파드가 다른데 성공했다. | 어느 쪽인가 | 인가 요청(state, CSRF)을 어디에 두는가 | replica 간 | |---|---|---| | BFF | 서버 메모리(HttpSession) | 콜백이 다른 인스턴스로 가면 실패 (B-0) | | oauth2-proxy | 쿠키 (secret 으로 봉인) | secret 만 같으면 성공 | 「어떻게 공유하는가」에 이 로그가 답한다 — replica 들이 나눠 가질 상태가 없고, 같아야 하는 값은 k8s Secret 하나다. 대신 그 하나가 단일 지점이 된다. ## 복구와 원상복구 확인표 ### 1. secret 참조를 A 로 되돌린다 **목적** — 실험 전 상태로 돌린다. **행동** — 시각을 남기고 되돌린다. ```bash label="[kc-lab-1] 되돌리는 것도 회전이다" date -u '+%H:%M:%S UTC 되돌림' 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_A"}]' kubectl -n keycloak-lab rollout status deploy/oauth2-proxy --timeout=180s ``` **예상 결과** — 롤아웃이 끝나고 파드 이름이 또 바뀐다. **왜 필요한가** — 되돌리기도 회전이므로 B 로 만든 세션이 이번에는 고아가 된다. **문제가 생기면** — 참조가 안 바뀌면 `env` 인덱스를 다시 본다. ### 2. 고아를 어떻게 할지 고른다 고아를 정리하는 선택지는 셋이다. | 무엇을 | 언제 | 어떻게 | |---|---|---| | 그냥 둔다 | 실험대 | TTL(1시간)이 지나면 사라진다 | | TTL 로 골라 지운다 | 산 세션을 살리고 싶을 때 | B-7a 의 규칙 | | 전부 지운다 | 어차피 다 무효일 때 | 아래 | 전부 지울 때는 `b5:pvc` 같은 남의 키를 같이 죽이지 않도록 패턴으로 좁힌다. `FLUSHDB` 를 쓰지 않는다 — 이 Redis 는 BFF 세션도 담고 있다. ```bash label="[kc-lab-1] 접두사에 걸린 것만 지운다" kubectl -n keycloak-lab exec deploy/redis -- \ redis-cli --scan --pattern '_oauth2_proxy-*' | while read K; do kubectl -n keycloak-lab exec deploy/redis -- redis-cli del "$K" done ``` ### 3. Grafana Ingress 를 돌려준다 **목적** — 빌린 도메인을 원래 주인에게 돌린다. **행동** — oauth2-proxy 것을 먼저 지우고 Grafana 것을 올린 뒤 밖에서 확인한다. **지우기 전에 백업 파일이 손에 있는지 본다.** 지금은 Grafana Ingress 가 이미 없으니 파일이 비어 있으면 다시 뜰 원본도 없고, 되살리는 절차는 가이드에 없다(unknown). 아래 두 줄의 첫째가 줄 수를 내고 둘째가 `0` 이 아니면 그 파일로 돌려줄 수 있다. `No such file or directory` 가 나오면 ①을 치지 않는다 — B-4 로 app2 를 빌린 적이 있으면 그 편은 같은 백업을 `/tmp/grafana-ingress-backup.yaml` 에 떠 두므로 그쪽을 본다. ```bash label="[kc-lab-1] ⓪ 백업 파일이 쓸 만한지 본다" wc -l ~/grafana-ingress-backup.yaml grep -c 'app2.hyeonworks.com' ~/grafana-ingress-backup.yaml ``` ```bash label="[kc-lab-1] ① 빌린 것을 걷고 백업을 올린다" kubectl -n keycloak-lab delete ingress oauth2-proxy kubectl apply -f ~/grafana-ingress-backup.yaml ``` ```bash label="[kc-lab-1 → test-server] ② Ingress 와 밖에서 본 응답을 함께 본다" kubectl -n observability get ingress grafana curl -sI https://app2.hyeonworks.com/ | head -3 ``` 첫 줄은 `kc-lab-1` 에서 치고, `curl` 은 1 절 ①과 같게 `test-server` 에서 친다. 기계가 다르면 1 절에서 본 응답과 견줄 수 없다. **예상 결과** — Ingress 가 `observability` 에 다시 있고 `app2` 응답이 1 절에서 처음 본 모양으로 돌아온다. **왜 필요한가** — 순서를 바꾸면 어느 쪽으로 갈지가 컨트롤러 판단에 맡겨진다. 그리고 되돌리지 않으면 실험이 끝나도 Grafana 가 안 열린다. **문제가 생기면** — oauth2-proxy 전체를 걷어내려면 `kubectl delete -f deploy/lab/k8s/b7-oauth2-proxy.yaml` 인데, B-7a 와 C-1 이 이 배포를 그대로 쓴다. 이어서 할 생각이면 남겨 두고, 그때는 Grafana Ingress 복구도 그 실험이 끝난 뒤로 미룬다. | 항목 | 명령 | 돌아왔을 때 | |---|---|---| | secret 참조 | `get deploy oauth2-proxy -o jsonpath='{...env[1]...key}'` | `COOKIE_SECRET_A` | | 파드 | `kubectl -n keycloak-lab get pods -l app=oauth2-proxy` | 둘 다 `1/1 Running` | | Redis | `redis-cli --scan --pattern '_oauth2_proxy-*'` | 남기기로 한 만큼만 | | Ingress (빌린 것) | `kubectl -n keycloak-lab get ingress` | oauth2-proxy 것이 없다 (걷어냈다면) | | Ingress (Grafana) | `kubectl -n observability get ingress grafana` | 있다 | | 밖 | `curl -sI https://app2.hyeonworks.com/ \| head -3` | Grafana 로 간다 | ## 막히면 원래 실행이 실제로 겪은 증상이고 지어낸 것은 없다고 가이드가 적는다. | 증상 | 원인 | 확인 | |---|---|---| | 콜백이 `502 Bad Gateway` | 쿠키가 크다. `Set-Cookie` 가 nginx 버퍼를 넘겼다 | Traefik 직접이 200 인지. Redis 세션 저장소로 옮긴다 | | 호스트에서 nginx 설정과 로그가 빈 결과 | `sudo` 가 조용히 실패했다 | `sudo -n true` → `sudo: a password is required` | | `--cookie-secrets` 를 찾는데 없다 | 단수다. 겹치는 구간이 애초에 없다 | `--help \| grep cookie-secret` | | patch 뒤 프록시가 기동에서 죽는다 | env 인덱스를 잘못 짚어 클라이언트 비밀을 덮었다 | `env[*].name` 순서 확인 | | secret 을 바꿨는데 Redis 가 그대로 | 정상이다. 옛 쿠키를 들고 오는 요청이 있어야 벌어진다 | 브라우저로 접근 | | 로그인 화면이 안 떠서 「무중단」이라 읽었다 | SSO 가 재인증을 가렸다. 쿠키는 죽었다 | 로그의 `Initiating login` 과 `AuthSuccess` | | 로그가 파드마다 섞여 못 읽겠다 | replica 가 둘이다 | `logs -l app=oauth2-proxy --prefix` | | `dbsize` 로 세션을 셌더니 안 맞는다 | `b5:pvc` 등 다른 키가 섞인다 | `--scan --pattern '_oauth2_proxy-*'` | | 파드 IP 로 `/oauth2/auth` 를 쳤더니 `HTTP 000` | 호스트에서 파드 IP 는 안 닿는다 | 공개 이름으로 치거나 클러스터 안 임시 파드를 쓴다 | | `curl` 로 OIDC 흐름을 완주하려다 실패 | 쿠키가 `HttpOnly` 이고 폼을 거쳐야 한다 | 브라우저를 쓴다 | | 로그 시각이 9시간 어긋난다 | 프록시 로그는 UTC | KST = UTC+9 | | `app2` 가 Grafana 로 간다 | Ingress 를 안 만들었거나 이미 복구했다 | `kubectl -n keycloak-lab get ingress` | | 실험이 끝났는데 Grafana 가 안 열린다 | Ingress 복구를 안 했다 | oauth2-proxy Ingress 를 먼저 지우고 백업을 올린다 | ## 무엇이 관측이고 무엇이 아닌가 이 절차의 숫자는 `2026-09-04 14:35–14:42 KST` 에 돈 한 번의 실행에서 나왔다(observed). 증거의 로그가 `[2026/09/04 05:41:46]` 인 것과 수집 시각이 `14:35–14:42 KST` 인 것은 같은 순간이다(KST = UTC+9). 이 어긋남을 모르고 로그를 뒤지면 9시간 전을 뒤지게 된다. - (observed) Grafana Ingress 삭제 한 줄과 배포 출력 여섯 줄, 파드 두 개(`oauth2-proxy-c76b49c59-8p5hl` @ `kc-lab-1` · `oauth2-proxy-c76b49c59-b9928` @ `kc-lab-2`), 진입점 `/ HTTP 302` · `/ping HTTP 200`, Traefik 직접의 `200` 과 `302`, `sudo -n true` → `sudo: a password is required`, 로그인 뒤 업스트림이 받은 헤더 네 줄, 회전 전 Redis 의 `b5:pvc` · `_oauth2_proxy-b26111fbd1fdab3ae2182e287001b02a` · `dbsize: 2`, `--cookie-secret string` 도움말 한 줄, 교체 출력 두 줄과 `현재 secret 키: COOKIE_SECRET_B`, 교체 직후 `Redis 세션은 그대로인가: 2 키`, `stored_session.go:94` 와 `97` 두 줄과 `Initiating login` · `AuthSuccess`, 회전 뒤 Redis 의 키 둘과 `총: 2 개`, replica 를 갈라 본 로그. - 비밀은 이름과 길이만 적었다. Secret 의 키 이름 셋(`CLIENT_SECRET` · `COOKIE_SECRET_A` · `COOKIE_SECRET_B`)과 16 · 24 · 32 바이트라는 제약만 옮겼고 값은 어디에도 안 적었다. 쿠키도 `||` 이라는 모양과 약 180자라는 길이만 옮겼다 — 지금 쓸 수 있는 세션 자격증명이라 원문은 해설 문서에 있다. Redis 키 이름과 파드 이름은 식별자라 그대로 적었다. - (unknown) `COOKIE_SECRET_A` 와 `B` 의 길이를 재는 두 줄. 가이드가 미검증으로 표시했고 원래 실행 기록에 이 명령의 출력이 없다. 16 · 24 · 32 바이트라는 제약은 oauth2-proxy 의 것이지 이 실험대가 잰 값이 아니다. - 예상이 빗나간 대목(observed) — 브라우저에서 로그인 화면이 안 떴다. 그것을 「무중단」으로 읽으면 뒤집어 읽은 것이고, 로그의 `Initiating login` 과 `AuthSuccess` 가 재인증이 실제로 돌았다는 값이다. IdP SSO 가 없거나 만료된 경우에 전원이 로그인 화면을 보는지는 재지 않았다(unknown). - 이 절차가 재지 않은 것 — 고아가 정말 사라지는지, 운영자가 지울 수 있는지, 어느 것이 고아인지는 여기서 재지 않고 B-7a 로 넘겼다. 502 를 고치는 다른 길(nginx 의 `proxy_buffer_size` 를 키우는 것)도 재지 않았다. 세션을 Redis 로 옮기는 쪽만 쟀다.