Front-Channel и Back-Channel Logout: почему ваш логаут не работает

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. Чтобы это действительно означало “вышел везде”, должно произойти следующее:

  1. Собственная SSO-сессия OP должна умереть, чтобы он не смог молча переиздать токены для A, B или C.
  2. Приложение B, сидящее в фоновой вкладке, должно об этом узнать. Ничто из того, что пользователь сделал в A, не затронуло cookie приложения B.
  3. 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 что-то очистил.

Где это ломается

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-провайдера

Как это реализовано в Versola

Сервис auth в Versola предоставляет RP-initiated logout на GET/POST /logout (id_token_hint, post_logout_redirect_uri, state). Если id_token_hint уже доказывает, какая сессия закрывается, страница подтверждения полностью пропускается, поскольку подтверждать больше нечего. При логауте auth:

На принимающей стороне 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 в документации.

← Все статьи