기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만 있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문 종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다. SSOT 결함 둘을 고쳤다. - kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이 「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽 `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다. - virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데 원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다. 기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은 둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더 든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다). 계약을 셋 고쳤다. - kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/** 28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는 것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온 것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수 대조했을 때 가장 가까운 커밋도 28개가 어긋났다). - virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고 재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다. - kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고 넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다. style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져 (실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고, engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고 있었다(Question 기록에서 11.94 → 3.86). verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
138 lines
11 KiB
Markdown
138 lines
11 KiB
Markdown
---
|
|
kind: CASE
|
|
slug: nobody-implemented-backchannel-logout
|
|
title: 백채널 로그아웃은 양쪽 다 없었다
|
|
topic: trust-handed-over-at-the-edge
|
|
topicName: 위조 신원 헤더와 로그아웃 전파
|
|
project: keycloak-session-store
|
|
status: 게시 전
|
|
lastVerifiedOn:
|
|
sourceRevision: cdac9b8178391311d8eca1ebc6cac15bb62d79af
|
|
source:
|
|
- final/document.md#선택이-코드와-흐름에-반영되는-방식-c층
|
|
assets:
|
|
- key: c2-backchannel-both-sides
|
|
file: ../../../final/assets/c2-backchannel-both-sides/c2-backchannel-both-sides.svg
|
|
evidence:
|
|
- ../../../final/evidence/raw/c2-backchannel-logout__01-current-state.txt
|
|
- ../../../final/evidence/raw/c2-backchannel-logout__03-logout-attempt.txt
|
|
- ../../../final/evidence/raw/c2-backchannel-logout__04-reachability.txt
|
|
---
|
|
|
|
# 백채널 로그아웃은 양쪽 다 없었다
|
|
|
|
로그아웃 통지를 보낼 backchannelLogoutUrl 도 받을 oidcLogout 엔드포인트도 없었다. 한 앱에서 로그아웃해도 다른 앱 세션이 끝나지 않았다. Keycloak 파드에서 앱 URL 로 요청하면 HTTP 200 이라 네트워크는 닿는다. 한쪽만 채워서는 전파되지 않는다.
|
|
|
|
## 관계
|
|
|
|
- **nginx 는 자기가 설정하지 않은 헤더를 덮어쓰지 않았다**
|
|
엣지가 만든 인증 결과가 앱까지 가는 경로를 재고, 이 실험은 그 인증을 끝내는 통지가 앱까지 가지 않는 경우를 쟀다.
|
|
- **믿기 전에 그 헤더를 먼저 지운다**
|
|
앱이 밖에서 온 값을 얼마나 믿을지 정하는 기준이고, 그 믿음을 거두는 통지가 여기서 끊겼다.
|
|
|
|
## 문제
|
|
|
|
백채널 로그아웃은 사용자가 한 앱에서 로그아웃하면 IdP 가 다른 앱에 서버 대 서버로 통지해 그쪽 세션도 끝내는 규격이다. 통지를 보내려면 IdP 가 부를 주소를 알아야 하고 앱에는 그 요청을 받아 세션을 지우는 엔드포인트가 있어야 한다.
|
|
|
|
C-1 에서 두 앱이 같은 realm 으로 SSO 되는 것을 확인하면서 로그아웃이 다른 앱으로 퍼지지 않는 것도 함께 관측했다. 설정이 빠진 것과 기능이 없는 것은 고치는 방법이 다른데, 로그아웃이 안 됐다는 결과만 보고는 어느 쪽인지 알 수 없다.
|
|
|
|
## 결론
|
|
|
|
두 클라이언트의 backchannelLogoutUrl : x
|
|
앱 소스의 수신 엔드포인트 (oidcLogout) : x
|
|
IdP 쪽만 채웠을 때 다른 앱 세션이 끝나는가 : x
|
|
Keycloak 파드에서 앱 URL 로 요청했을 때 : HTTP 200
|
|
|
|
네트워크는 닿는다. 빠진 것은 경로가 아니라 양쪽 끝의 구현이다.
|
|
|
|
통지를 보내는 쪽과 받는 쪽이 함께 빠져 있어서 한쪽만 채워서는 로그아웃이 전파되지 않는다. 확인 순서를 IdP 설정부터 시작해 거기서 멈췄다면 설정 하나를 채워 넣고 고쳤다고 판단했을 수 있다.
|
|
|
|
## 검증 환경
|
|
|
|
Keycloak realm : 두 앱이 같은 realm 을 쓴다
|
|
클라이언트 : 2개
|
|
앱 : 두 앱 모두 백채널 로그아웃 수신 엔드포인트 없음
|
|
실험대 : 베어메탈 test-server 한 대 위에 VM 두 대
|
|
test-server : Arch Linux, 12GB, WiFi only
|
|
kc-lab-1 : k3s server (컨트롤 플레인) · keycloak-1
|
|
kc-lab-2 : k3s agent · keycloak-0 · PostgreSQL · Redis
|
|
분석 리비전 : cdac9b8178391311d8eca1ebc6cac15bb62d79af
|
|
실행일 : 이 측정 기록에 적혀 있지 않다
|
|
|
|
## 재현 조건
|
|
|
|
1. 한 realm 에 클라이언트 둘을 만들고 앱 둘을 각각 붙인다.
|
|
|
|
2. 앱1 에 로그인한 뒤 앱2 를 열어 로그인 화면 없이 통과하는지 본다.
|
|
|
|
3. 앱1 에서 로그아웃하고 앱2 의 세션이 끝났는지 본다.
|
|
|
|
4. 두 클라이언트에 백채널 로그아웃 주소가 설정돼 있는지 확인한다.
|
|
kcadm get clients -r <realm> --fields clientId,attributes | grep -i backchannel
|
|
|
|
5. 앱 소스에 그 요청을 받는 엔드포인트가 있는지 확인한다.
|
|
|
|
6. IdP 쪽에만 주소를 채우고 다시 로그아웃해 앱2 의 세션을 확인한다.
|
|
|
|
7. Keycloak 파드에서 앱 URL 로 요청해 응답 코드를 확인한다.
|
|
|
|
## 본문
|
|
|
|
<!-- body:start -->
|
|
## SSO 는 됐고 로그아웃은 따라가지 않았다
|
|
|
|
C-1 에서 앱 둘을 같은 realm 에 붙였다. 앱1 에 로그인한 상태로 앱2 를 열면 로그인 화면 없이 통과한다. 같은 브라우저가 이미 Keycloak 에 로그인해 있으니 앱2 는 그 결과를 그대로 받는다.
|
|
|
|
앱1 에서 로그아웃한 뒤에도 앱2 는 계속 로그인 상태였다. 세션이 두 겹이기 때문인데, Keycloak 이 갖는 SSO 세션과 앱이 자기 사용자를 기억하는 애플리케이션 세션은 다른 것이라 한쪽을 끝낸다고 다른 쪽이 따라 끝나지 않는다. 앱2 의 세션을 끝내려면 누군가 앱2 에 그 사실을 알려야 한다.
|
|
|
|
그 통지를 맡는 규격이 백채널 로그아웃이다. 사용자가 한 앱에서 로그아웃하면 IdP 가 다른 앱에 서버 대 서버로 요청을 보내 그쪽 세션도 끝낸다. 브라우저를 거치지 않으므로 사용자가 그 앱 화면을 열고 있지 않아도 전파된다.
|
|
|
|
## 네 가지를 순서대로 확인했다
|
|
|
|
C-2 에서 물음을 넷으로 나눴다. 원인 후보가 셋이었고 판정하는 방법이 서로 달랐다. IdP 쪽 설정은 클라이언트 속성을 읽어야 알고, 앱 쪽 기능은 소스와 배포된 경로를 둘 다 봐야 알고, 네트워크 도달은 클러스터 안에서 직접 쳐야 안다. 셋 중 하나가 원인일 것으로 보고 시작했다.
|
|
|
|
| 무엇을 물었나 | 무엇이 나왔나 |
|
|
|---|---|
|
|
| 백채널 로그아웃이 설정되어 있었는가 | 아니다 — 두 클라이언트 모두 `backchannelLogoutUrl` 없음 |
|
|
| 앱에 그 엔드포인트가 있는가 | 아니다 — 소스에 `oidcLogout` 설정이 없다 |
|
|
| IdP 쪽만 설정하면 되는가 | 안 된다 — 앱 세션이 끝나지 않았다 |
|
|
| Keycloak 이 앱 URL 에 닿기는 하는가 | 닿는다 — `HTTP 200` |
|
|
|
|
첫 물음은 Keycloak 의 클라이언트 설정을 직접 읽어 답했다. 걸러 낸 출력이 비었다는 것만으로는 답이 되지 않는다. 빈 출력은 「없다」와 「명령이 안 먹었다」를 구별해 주지 않기 때문이다. 그래서 속성을 통째로 받아 훑었고, 두 클라이언트 모두 `frontchannelLogout` 은 보이는데 `backchannelLogoutUrl` 이 없었다. 다른 값이 보인다는 것이 명령은 먹었다는 증거다.
|
|
|
|
```bash label="클라이언트에 백채널 로그아웃 주소가 있는지 본다"
|
|
kcadm get clients -r <realm> --fields clientId,attributes | grep -i backchannel
|
|
# 그리고 앱 쪽에 수신 엔드포인트가 있는지 소스에서 확인한다
|
|
```
|
|
|
|
둘째 물음은 소스부터 봤다. `oidcLogout` 을 켜지 않으면 `/logout/connect/back-channel/{registrationId}` 경로가 생기지 않으므로, 소스에 없으면 경로도 없다. 다만 소스에 없다는 것과 배포된 앱에 없다는 것은 다른 주장이라 배포된 쪽도 직접 쳤다. `/logout/connect/back-channel/keycloak` 도 `/backchannel-logout` 도 `/oauth2/sign_out` 도 `HTTP 302` 였다. 엔드포인트가 있었다면 요청 본문의 logout token 을 읽고 200 이나 400 을 돌려줬을 것이므로, 302 는 그런 핸들러가 없어 인증 요구로 떨어졌다는 뜻이다.
|
|
|
|
셋째 줄은 재기 전에 한 번 헛돌았다. 로그아웃을 걸었는데 그 시점 realm 의 세션 수가 0 이었고, 끊을 대상이 없으니 앱 세션이 그대로인 것은 당연한 결과였다. 명령은 정상적으로 실행됐고 출력도 그럴듯했고 결론도 원하던 방향이었는데 틀린 것은 전제뿐이라, 그 판을 버리고 브라우저로 로그인해 세션을 하나 만든 뒤 다시 걸었다.
|
|
|
|
IdP 쪽에 주소를 채우는 것도 한 번에 되지 않았다. 속성을 점 표기로 준 첫 명령이 종료 코드 1 로 끝났는데, 속성 이름 자체에 점이 들어 있어 관리 명령의 점 표기와 충돌하기 때문이다. JSON 으로 통째로 넘겨서 넣었다.
|
|
|
|
그렇게 세션이 살아 있는 판에서 주소만 채우고 다시 로그아웃했는데도 앱 세션은 개수도 이름도 그대로였다. 표의 셋째 줄이 이 실험의 답이고, 빠진 것이 설정 하나가 아니었다.
|
|
|
|
넷째 물음은 앞의 둘과 대조하려고 넣었다. 앱 세션이 안 지워지는 까닭이 요청이 못 닿아서라면 고쳐야 하는 것은 구현이 아니라 네트워크이고, 그때는 앞의 두 답을 알아도 소용이 없다. Keycloak 이미지에는 `curl` 도 `wget` 도 없어서 같은 네임스페이스에 임시 파드를 띄워 쳤고, 이름이 풀리고 `HTTP 200` 이 왔다.
|
|
|
|
Keycloak 로그도 훑었다. 로그 전체에서 `backchannel` 이 들어간 줄이 keycloak-0 도 keycloak-1 도 0 줄이었는데, 이것은 안 보냈다는 증거가 아니라 기본 로그 레벨에서는 안 보인다는 뜻이다. 0 줄을 근거로 「보내지 않았다」를 쓰면 나중에 디버그 로그를 켜서 보냈다는 것이 드러날 때 결론 전체가 함께 넘어간다. 확실한 것은 앱 세션이 안 지워졌다는 관측이고 그것은 직접 봤다.
|
|
|
|
## 설정이 빠진 것과 기능이 없는 것
|
|
|
|

|
|
|
|
그림의 통지 경로에는 화살표 둘이 차례로 놓여 있고 둘 다 조건이 붙어 있다. `backchannelLogoutUrl` 이 있어야 Keycloak 이 앱을 부르고, 앱에 수신 엔드포인트가 있어야 그 호출이 세션 삭제로 이어진다. 이 실험대에서는 앞 화살표도 뒤 화살표도 성립하지 않았다.
|
|
|
|
설정이 빠진 것과 기능이 없는 것은 고치는 방법이 다르다. 앞쪽은 Keycloak 클라이언트에 값을 채우는 일이고 뒤쪽은 앱에 코드를 넣는 일이다. 여기는 둘 다였으므로 IdP 설정만 확인하고 멈췄다면 값을 하나 채운 뒤 고쳤다고 판단했을 수 있다. 물음을 넷으로 나눠 순서대로 확인한 덕분에 그 판단을 하지 않았다.
|
|
|
|
뒤쪽 일의 크기도 두 앱이 같지 않다. 앱1 은 수신 엔드포인트를 켤 수 있지만, 앱2 가 쓰는 oauth2-proxy 는 백채널 로그아웃을 지원하지 않아 다른 방안을 찾아야 한다. 같은 realm 으로 SSO 를 묶어 두어도 로그아웃 전파는 앱마다 다르게 끝난다.
|
|
|
|
## 원인 확정까지가 이 실험의 범위다
|
|
|
|
양쪽을 다 구현해서 로그아웃이 실제로 전파되는지는 확인하지 않았다. 여기서 닫은 것은 「왜 안 되는가」까지다.
|
|
|
|
`HTTP 200` 도 도달만 확인한 값이다. 앱 URL 로 요청하면 응답이 온다는 것까지 봤고, 그 URL 이 로그아웃 통지를 처리하는지는 보지 않았다.
|
|
|
|
그 200 에는 이 실험대의 사정이 섞여 있다. tailnet 과 split DNS 로 묶여 있어 클러스터 안에서 공개 이름을 불러도 되돌아오는데, 앱이 사설망에 있고 IdP 가 밖에 있는 구성에서는 설정을 다 채워도 통지가 도달하지 못하고 그때는 로그도 안 남고 조용히 실패한다. 그리고 요청을 실제로 친 것은 Keycloak 파드가 아니라 같은 네임스페이스에 띄운 임시 파드다. NetworkPolicy 나 사이드카가 걸려 있으면 둘의 결과가 갈릴 수 있고, 이 실험대에는 그런 것이 없어서 대신 친 값을 그대로 썼다.
|
|
<!-- body:end -->
|