·
Front-Channel и Back-Channel Logout: почему ваш логаут не работает
Выход из одного приложения не завершает сессию пользователя во всех приложениях в рамках SSO. Как на самом деле работают front-channel и back-channel logout в OIDC, реальные HTTP-примеры и где каждый из них незаметно ломается.
Выход пользователя из одного приложения не означает выход из всех приложений в рамках единой сессии (SSO). Очистка cookie одного приложения завершает сессию только с этим приложением. Front-channel logout уведомляет остальные приложения через браузер, в скрытых iframe; back-channel logout уведомляет их напрямую, сервер-сервер, вообще без участия браузера. Большинство багов “логаут не работает” возникают из-за того, что реализован только один из двух механизмов, или ни один, а редирект на страницу входа принимается за доказательство, что пользователь вышел из всех сессий сразу.
Сценарий
У пользователя открыто три приложения, все аутентифицированы через один identity provider (OP):
- Приложение A — биллинговый dashboard, открыт в текущей вкладке.
- Приложение B — админ-консоль, открыта в фоновой вкладке.
- Приложение C — мобильное приложение, чей backend держит сессию, привязанную к тому же логину, без единой вкладки браузера.
Пользователь нажимает Выйти в приложении A. Чтобы это действительно означало “вышел везде”, должно произойти следующее:
- Собственная SSO-сессия OP должна умереть, чтобы он не смог молча переиздать токены для A, B или C.
- Приложение B, сидящее в фоновой вкладке, должно об этом узнать. Ничто из того, что пользователь сделал в A, не затронуло cookie приложения B.
- Backend приложения C тоже должен узнать об этом, и до него нельзя дотянуться ничем из того, что браузер делает от имени пользователя.
Шаг 2 решает front-channel logout. Шаг 3 решает back-channel logout. Реализация логаута, которая только очищает собственный cookie приложения A, не решает ни один из трех пунктов: пользователь “вышел” из того приложения, в котором нажал кнопку, и остается залогинен во всем остальном, пока эти сессии живы.
Front-channel logout: уведомление через браузер
OP рендерит страницу, содержащую один скрытый <iframe> на каждого relying party (RP), участвовавшего в сессии, каждый указывает на зарегистрированный front-channel logout URI этого RP. Браузер загружает все iframe в фоне; каждый RP сбрасывает завершаемую сессию и возвращает 200 OK с пустым телом. Пользователь всего этого не видит: это происходит, пока браузер рендерит собственную страницу OP после логаута.
Как именно RP поймет, какую сессию сбрасывать, зависит от регистрации. Если клиент зарегистрирован с frontchannel_logout_session_required, OP добавляет к logout URI параметры iss (свой issuer) и sid (ID сессии), и RP может определить сессию только по query string. Этот флаг по умолчанию false, а оба параметра в спецификации опциональны, так что у RP, который их не запросил, остается только собственный cookie. Это ровно тот случай, который ломает политика браузеров по cookie (см. Где это ломается ниже).
sequenceDiagram
autonumber
participant B as Browser
participant OP as auth (OP)
participant RA as RP A (frontchannel)
participant RB as RP B (frontchannel)
note over B: User clicks "Log out" in RP A
B->>OP: GET /logout?post_logout_redirect_uri=…&id_token_hint=…
OP->>OP: Invalidate SSO session, list session participants
OP-->>B: 200 HTML — one hidden iframe per participant
par Browser loads each iframe in the background
B->>RA: GET /logout/frontchannel?iss=…&sid=…
RA->>RA: Clear session for sid
RA-->>B: 200 OK
and
B->>RB: GET /logout/frontchannel?iss=…&sid=…
RB->>RB: Clear session for sid
RB-->>B: 200 OK
end
B->>B: Redirect to post_logout_redirect_uri
Конкретный обмен, начиная с запроса браузера к endpoint логаута OP:
GET /logout?post_logout_redirect_uri=https%3A%2F%2Fapp-a.example.com%2F&id_token_hint=eyJhbGciOiJSUzI1NiJ9... HTTP/1.1
Host: id.versola.kz
OP инвалидирует собственную сессию и для каждого RP с зарегистрированным frontchannel_logout_uri добавляет скрытый iframe, указывающий на него. Браузер загружает:
GET /logout/frontchannel?iss=https%3A%2F%2Fid.versola.kz&sid=8f14e45f-ceea-4a2b-9d61-1e2f3a4b5c6d HTTP/1.1
Host: app-b.example.com
HTTP/1.1 200 OK
Cache-Control: no-store
Когда они отправляются, iss и sid — это все, что получает RP; никакой аутентификации на этом запросе, кроме “принадлежит ли этот ID сессии сессии, которую я отслеживаю, и совпадает ли iss с тем OP, которому я доверяю”. Это намеренно: это GET-запрос, загружаемый в iframe, поэтому он физически не может нести ничего более чувствительного.
Back-channel logout: прямое уведомление сервера
Front-channel logout срабатывает, только если есть браузер, который его выполнит. Это реальное ограничение, и дело не только в том, открыта ли вкладка: iframe загружаются в том user agent, где был инициирован логаут, поэтому до backend приложения C ничего не дойдет, если пользователь вышел из самого мобильного приложения, если сессию завершил администратор или если ее отозвали с другого устройства. Даже в случае с браузером пользователь может уйти со страницы до того, как iframe догрузятся.
Back-channel logout убирает браузер из уравнения полностью: OP вызывает backchannel_logout_uri RP напрямую, сервер-сервер, с подписанным logout_token: JWT, созданным специально для этой цели, а не переиспользованным id_token RP.
sequenceDiagram
autonumber
participant B as Browser
participant OP as auth (OP)
participant RB as RP B backend
note over RB: Holds a session for this user, reachable only server-to-server
note over B: User clicks "Log out" in RP A
B->>OP: GET /logout (SSO session cookie)
OP->>OP: Invalidate SSO session, list session participants
par Dispatched in the background — the logout response never waits
OP->>RB: POST /logout/backchannel (logout_token=…)
RB->>RB: Verify signature, iss, aud, events, no nonce
RB->>RB: Delete session by sid
RB-->>OP: 200 OK (nothing is blocked on this)
and
OP-->>B: Redirect to post_logout_redirect_uri
end
POST /logout/backchannel HTTP/1.1
Host: app-c-backend.example.com
Content-Type: application/x-www-form-urlencoded
logout_token=eyJhbGciOiJSUzI1NiIsImtpZCI6ImtpZC0xIn0.eyJpc3MiOiJodHRwczovL2lkLnZlcnNvbGEua3oiLCJzdWIiOiJ1c3JfNDQxMSIsImF1ZCI6WyJhcHAtYyJdLCJqdGkiOiJiN2YyYzlkZS0uLi4iLCJpYXQiOjE3NTU3NzYwMDAsImV4cCI6MTc1NTc3NjEyMCwic2lkIjoiOGYxNGU0NWYtLi4uIiwiZXZlbnRzIjp7Imh0dHA6Ly9zY2hlbWFzLm9wZW5pZC5uZXQvZXZlbnQvYmFja2NoYW5uZWwtbG9nb3V0Ijp7fX19...
В раскодированном виде claims этого токена:
{
"iss": "https://id.versola.kz",
"sub": "usr_4411",
"aud": ["app-c"],
"jti": "b7f2c9de-...",
"iat": 1755776000,
"exp": 1755776120,
"sid": "8f14e45f-ceea-4a2b-9d61-1e2f3a4b5c6d",
"events": {
"http://schemas.openid.net/event/backchannel-logout": {}
}
}
Именно claim events положительно идентифицирует токен как logout-токен, и он обязателен: именно на эту проверку опирается RP, чтобы понять, что у него в руках. Спецификация дополнительно запрещает claim nonce, но это скорее дополнительное защитное правило, чем идентифицирующее: id_token содержит nonce, только если его передали в authorization request, так что само по себе его отсутствие ничего не доказывает. Отклоняя любой токен, который nonce содержит, сервер не дает перехваченному id_token быть переиспользованным как сигнал логаута. Это важно, потому что сервис, принимающий id_token вместо logout-токена, позволил бы любому его держателю принудительно завершать чужие сессии.
При успехе:
HTTP/1.1 200 OK
Cache-Control: no-store
При токене, не прошедшем валидацию:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{"error":"invalid_request","error_description":"logout token must not carry a nonce"}
RP не обязан быть доступен из браузера, никогда не видит cookie сессии пользователя, а браузер, инициировавший логаут, никогда не узнает, действительно ли backend приложения C что-то очистил.
Где это ломается
Блокировка сторонних cookie незаметно убивает logout только на front-channel
Front-channel iframe загружается с origin RP, в контексте браузера, который все чаще не дает сторонним iframe читать cookie, нужные для определения, какую сессию чистить. Если реализация полагается внутри этого iframe на обычный cookie сессии браузера вместо query-параметра sid, чтобы определить сессию, то ITP в Safari и поэтапный отказ от сторонних cookie в Chrome приводят к тому, что iframe загружается, возвращает 200 OK и не очищает ничего. Именно поэтому back-channel logout существует как второй, независимый канал. Это не дублирование front-channel, а резервный вариант для браузеров, которые партиционируют или блокируют первый.
Доставка back-channel устроена по принципу fire-and-forget
Уведомление всех RP из обработчика /logout OP синхронно означает, что один медленный или недоступный RP подвесит или сломает логаут для всех пользователей. Поэтому доставка back-channel обычно происходит в фоне, каждая ограничена коротким таймаутом (в Versola: 5 секунд, без повторов), не блокируя ответ, которого ждет браузер. Это правильный компромисс для пользователя перед браузером, но он означает, что back-channel logout может незаметно провалиться: если endpoint RP недоступен, сессия OP уже удалена, а копия у RP живет, пока не истечет сама. Повторы тут не спасают: logout-токен валиден всего пару минут, так что отложенный повтор в основном тоже не успевает дойти вовремя. Настоящее средство — держать время жизни сессии и токена на стороне RP достаточно коротким, чтобы это окно не имело значения.
Уведомление клиентов, которые никогда не участвовали в сессии
Другой тип ошибки, отличный от первых двух: реализации, которые рассылают логаут всем клиентам, зарегистрированным в тенанте, вместо только тех клиентов, что реально участвовали в конкретной SSO-сессии. Это одновременно расточительно (вызовы RP, которых пользователь никогда не касался) и является ошибкой корректности (sid, который эти RP никогда не видели). Это также значит, что OP обязан реально отслеживать по каждой сессии, с какими клиентами она использовалась (список заполняется при выдаче токенов клиенту и обновляется при silent re-authorization), а не воспринимать “логаут” как “вызвать всех, кого мы знаем”.
post_logout_redirect_uri как доверенный ввод
post_logout_redirect_uri контролируется атакующим. Он приходит в query string запроса GET /logout, который любой может сформировать и отправить жертве. OP, безусловно редиректящий на него после логаута, получает open redirect: https://id.example.com/logout?post_logout_redirect_uri=https://evil.example.com/ отправляет пользователя на страницу атакующего сразу после того, как ему только что сказали “вы вышли из системы”, именно в момент, когда он меньше всего этого ожидает. Решение то же, что применяется к redirect_uri в OAuth: проверять его по зарегистрированному allow-list, прежде чем следовать ему, и молча отбрасывать (никуда не редиректить или вести на страницу по умолчанию), если совпадения нет.
Собственная сессия OP переживает сессию RP
Обратная сторона первых трех: кнопка “Выйти” в приложении, которая только очищает свой локальный cookie и вообще не вызывает end_session_endpoint OP. Приложение выглядит вышедшим из системы, но cookie SSO-сессии OP все еще жив. Следующая попытка silent re-authentication (скрытая iframe-проверка с prompt=none или просто повторный клик на “войти”) молча залогинивает пользователя обратно без единого запроса, потому что с точки зрения OP ничего не произошло. Именно этот сценарий порождает баг-репорты вида “я вышел, а меня снова залогинило”: RP выполнил свою половину работы и на этом остановился.
Чек-лист для оценки логаута у OIDC-провайдера
- Реализует ли он оба механизма, front-channel и back-channel logout, а не только один?
- Отслеживает ли он, какие клиенты реально участвовали в сессии, вместо уведомления всех зарегистрированных клиентов?
- Проверяет ли
post_logout_redirect_uriпо зарегистрированному списку вместо безусловного редиректа на него? - Выполняется ли доставка back-channel в фоне с ограниченным таймаутом, чтобы один недоступный RP не подвесил логаут всем пользователям?
- Отклоняет ли он
logout_tokenс claimnonceи требует ли claimevents? Это две проверки, которые не дают переиспользоватьid_tokenкак сигнал логаута.
Как это реализовано в Versola
Сервис auth в Versola предоставляет RP-initiated logout на GET/POST /logout (id_token_hint, post_logout_redirect_uri, state). Если id_token_hint уже доказывает, какая сессия закрывается, страница подтверждения полностью пропускается, поскольку подтверждать больше нечего. При логауте auth:
- определяет реальных участников сессии из записи сессии: клиентов, зарегистрированных за этой сессией при выдаче токена или silent re-auth, а не всех клиентов тенанта;
- строит front-channel logout URI для каждого клиента с зарегистрированным
frontchannel_logout_uri; - рассылает back-channel
logout_tokenпараллельно, каждый в своем fiber, с таймаутом 5 секунд и без повторов, чтобы недоступный RP никогда не задерживал ответ на логаут для кого-то еще; - проверяет
post_logout_redirect_uriпо зарегистрированным redirect URI тенанта, прежде чем на него редиректить.
На принимающей стороне edge (компонент, стоящий перед интегрированным приложением) реализует оба endpoint’а: GET /logout/frontchannel (проверяет iss по настроенному OP, очищает cookie EDGE_SESSION для этой сессии) и POST /logout/backchannel (проверяет подпись logout_token, issuer, audience, claim events и безусловно отклоняет токен с claim nonce, по чек-листу выше).
Настройка обоих URI на стороне клиента находится в сущности client. См. настройку front-channel и back-channel logout в документации.