--- 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 --fields clientId,attributes | grep -i backchannel 5. 앱 소스에 그 요청을 받는 엔드포인트가 있는지 확인한다. 6. IdP 쪽에만 주소를 채우고 다시 로그아웃해 앱2 의 세션을 확인한다. 7. Keycloak 파드에서 앱 URL 로 요청해 응답 코드를 확인한다. ## 본문 ## 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 --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 줄을 근거로 「보내지 않았다」를 쓰면 나중에 디버그 로그를 켜서 보냈다는 것이 드러날 때 결론 전체가 함께 넘어간다. 확실한 것은 앱 세션이 안 지워졌다는 관측이고 그것은 직접 봤다. ## 설정이 빠진 것과 기능이 없는 것 ![Keycloak 이 부를 주소와 앱이 받을 엔드포인트가 각각 비어 있어 로그아웃 통지가 어느 쪽에서도 성립하지 않는 구성.](../../../final/assets/c2-backchannel-both-sides/c2-backchannel-both-sides.svg) 그림의 통지 경로에는 화살표 둘이 차례로 놓여 있고 둘 다 조건이 붙어 있다. `backchannelLogoutUrl` 이 있어야 Keycloak 이 앱을 부르고, 앱에 수신 엔드포인트가 있어야 그 호출이 세션 삭제로 이어진다. 이 실험대에서는 앞 화살표도 뒤 화살표도 성립하지 않았다. 설정이 빠진 것과 기능이 없는 것은 고치는 방법이 다르다. 앞쪽은 Keycloak 클라이언트에 값을 채우는 일이고 뒤쪽은 앱에 코드를 넣는 일이다. 여기는 둘 다였으므로 IdP 설정만 확인하고 멈췄다면 값을 하나 채운 뒤 고쳤다고 판단했을 수 있다. 물음을 넷으로 나눠 순서대로 확인한 덕분에 그 판단을 하지 않았다. 뒤쪽 일의 크기도 두 앱이 같지 않다. 앱1 은 수신 엔드포인트를 켤 수 있지만, 앱2 가 쓰는 oauth2-proxy 는 백채널 로그아웃을 지원하지 않아 다른 방안을 찾아야 한다. 같은 realm 으로 SSO 를 묶어 두어도 로그아웃 전파는 앱마다 다르게 끝난다. ## 원인 확정까지가 이 실험의 범위다 양쪽을 다 구현해서 로그아웃이 실제로 전파되는지는 확인하지 않았다. 여기서 닫은 것은 「왜 안 되는가」까지다. `HTTP 200` 도 도달만 확인한 값이다. 앱 URL 로 요청하면 응답이 온다는 것까지 봤고, 그 URL 이 로그아웃 통지를 처리하는지는 보지 않았다. 그 200 에는 이 실험대의 사정이 섞여 있다. tailnet 과 split DNS 로 묶여 있어 클러스터 안에서 공개 이름을 불러도 되돌아오는데, 앱이 사설망에 있고 IdP 가 밖에 있는 구성에서는 설정을 다 채워도 통지가 도달하지 못하고 그때는 로그도 안 남고 조용히 실패한다. 그리고 요청을 실제로 친 것은 Keycloak 파드가 아니라 같은 네임스페이스에 띄운 임시 파드다. NetworkPolicy 나 사이드카가 걸려 있으면 둘의 결과가 갈릴 수 있고, 이 실험대에는 그런 것이 없어서 대신 친 값을 그대로 썼다.