---
title: "Okta Universal Logout — истёкший черновик — FoxAuth"
description: "Okta Universal Logout построен на истёкшем черновике стандарта. Разбираю, что присылает Okta, где ловушка с nbf и какие проверки нельзя ослабить настройкой."
canonical: "https://foxauth.dev/ru/blog/okta-universal-logout-is-an-expired-draft/"
lastmod: "2026-10-08T21:45:57+03:00"
---
Блог
Okta Universal Logout — истёкший черновик
Okta Universal Logout построен на истёкшем черновике стандарта. Разбираю, что присылает Okta, где ловушка с nbf и какие проверки нельзя ослабить настройкой.
Автор: FoxAuth · 2026-10-08

Требование звучало просто. Сотрудники компании входят на наш сервер авторизации через корпоративного
провайдера удостоверений (IdP). Это сервис, где хранятся учётные записи сотрудников и где они входят.
Иногда служба безопасности компании решает, что чей-то доступ надо закрыть — везде и прямо сейчас.
Тогда провайдер должен суметь сообщить об этом нам. Не при следующем входе человека: этот вход
может случиться только завтра. А сейчас — и сессии, и refresh-токены, и всё остальное.

Я пошёл искать стандартный способ, которым провайдер говорит приложению: «закрой всё, что есть у этого
человека». Такое приложение называют доверяющей стороной (relying party): проверку входа оно доверяет
провайдеру. Стандартного способа нет. Есть финальная спецификация, но для более узкой задачи. Есть
другая, для более широкой, но её сообщения крупные провайдеры приложениям других компаний не
отправляют. И есть индивидуальный Internet-Draft. Internet-Draft — это рабочий черновик будущего
стандарта. «Индивидуальный» значит, что его написал сам автор, а не рабочая группа. Этот черновик так и
не приняли, и срок его действия истёк. Именно его и отправляет Okta.

Из крупных корпоративных провайдеров «закрой всё» отправляет только Okta, и по истёкшему черновику

Вот что каждый провайдер на самом деле отправит приложению вроде нашего (на октябрь 2026 года). Это
меньше, чем обещают маркетинговые страницы.

Back-channel logout — финальная спецификация OpenID, и хорошая. По ней провайдер сообщает приложению о
выходе напрямую, от сервера к серверу, без участия браузера. Но сообщает он о выходе, а не о взломе.
Закрываются только сессии, которые появились из одного входа у провайдера (одной upstream-сессии). А
офлайн-доступ, который позволяет приложению работать, пока пользователя нет, по замыслу должен такой
выход пережить.

Shared Signals и CAEP — широкий стандарт для сигнала «эта сессия отозвана». Но ни один крупный
корпоративный провайдер не отправляет эти сигналы любой доверяющей стороне. Entra приложению другой
компании не отправляет вообще ничего.

Остаётся Universal Logout от Okta. За ним стоит черновик draft-parecki-oauth-global-token-revocation.
Ревизия 06 датирована февралём 2026 года. Срок её действия истёк 28 августа, а рабочая группа OAuth её
так и не приняла. Преемника с префиксом draft-ietf- тоже нет (такой префикс получает черновик, который
приняла рабочая группа). Мы изучили шестнадцать похожих продуктов. Такой запрос принимает из них ещё
ровно один — Auth0.

Я всё равно это сделал и думаю, что правильно. Но только с тремя страховками — теперь я требовал бы их
для всего, что построено на истёкшем черновике. Первая: мы принимаем запрос в том виде, в каком его
описывает документация Okta. Черновик мы не читаем так, будто это уже стандарт. Вторая: всё спрятано за
отдельным переключателем globalTokenRevocation.enabled. По умолчанию он выключен, а в его описании в
консоли прямо сказано, что срок черновика истёк. Третья: формат передачи (что именно и в каком виде
приходит по сети) живёт в одном небольшом модуле. Под модулем — ядро, которое об этом формате ничего не
знает. Если формат изменится или умрёт, часть, которая решает, кто может закрыть чей доступ, останется
как есть.

Что присылает Okta, кого она может назвать и что мы закрываем

Okta отправляет POST-запрос. В его JSON-теле назван пользователь. А в заголовке Authorization лежит
JWT, подписанный ключами самой Okta:

POST /acme/global-token-revocation HTTP/1.1

Host: auth.example.com

Authorization: Bearer eyJ0eXAiOiJnbG9iYWwtdG9rZW4tcmV2b2NhdGlvbitqd3QiLCJhbGciOiJSUzI1NiJ9...

Content-Type: application/json

{ "sub_id": { "format": "iss_sub", "iss": "https://acme.okta.com", "sub": "00u1a2b3c4d5e6f7g8h9" } }

Если раскодировать этот JWT, видно, кто спрашивает и для какого эндпоинта. Дальше я буду называть его
утверждением (assertion). Так называют подписанное заявление, которое одна сторона передаёт другой.

{ "typ": "global-token-revocation+jwt", "alg": "RS256", "kid": "q8J1..." }

{

"iss": "https://acme.okta.com",

"sub": "0oa1b2c3d4e5f6g7h8i9",

"aud": "https://auth.example.com/acme/global-token-revocation",

"iat": 1759917600,

"nbf": 1759917300,

"exp": 1759917900,

"jti": "1a8c3e0f-5b2d-4c7e-9f61-2d0b8a4e7c35"

}

iss — это организация в Okta. sub — наш client id в этой организации. Документация Okta описывает
его как client_id/appInstanceId; какая форма приходит на самом деле, покажет только настоящий прогон.
Вместе эти два поля указывают на одного внешнего провайдера (upstream-провайдера, то есть сервиса,
через который наши пользователи входят) в одном бакете пользователей. Бакет — так наш сервер называет группу
пользователей со своими правилами входа. Когда у бакета появляется свой адрес, он выпускает токены от
своего имени, то есть становится отдельным издателем (issuer).

sub_id в теле указывает на человека: по его subject (идентификатору у этого провайдера) или, в другом
формате Okta, по email. Назвать можно не любого. Провайдер может назвать только того, кто входит к нам
через этого провайдера, или того, кого создало привязанное к нему SCIM-подключение. (SCIM — протокол, по
которому каталог пользователей компании сам заводит и отключает учётные записи в других системах.
Подключение провижининга — запись, которая связывает один каталог с одним бакетом.) Все остальные
получают одинаковый 404, даже несуществующие люди. Поэтому через этот эндпоинт нельзя выяснить, у
кого есть учётная запись.

Запрос закрывает всё, что есть у человека: сессии, выданные разрешения (grant — то, что пользователь
разрешил приложениям), refresh-токены и opaque-токены доступа. Opaque-токен — это случайная строка без
содержимого, о которой знает только сервер. Каждой доверяющей стороне тоже уходит back-channel logout.
Сама учётная запись остаётся активной. Человек может сразу войти снова, но согласие ему придётся дать
заново. Обязательный минимум черновика (его MUST) меньше: отозвать refresh-токены и заставить
пользователя войти заново. Делать больше черновик разрешает.

Закрыть доступ можно разными путями: действием администратора, этим запросом от Okta, деактивацией
через SCIM. Все они проходят через одну и ту же операцию, и это сознательное решение. Наши
администраторы получают то же самое кнопкой «Sign out everywhere» («выйти везде»).

Считайте срок жизни утверждения от iat, а не от nbf, иначе отклоните каждый настоящий запрос

Посмотрите на три отметки времени в содержимом этого JWT. iat — когда утверждение выпущено. nbf
(«not before», «не раньше») — момент, до которого оно недействительно. exp — когда оно истекает. nbf
стоит на пять минут раньше iat, а exp — на пять минут позже. Okta делает так нарочно: если часы
получателя немного расходятся с часами Okta, запрос всё равно пройдёт. Это совершенно законный приём.

Мы ограничиваем срок жизни предъявленного утверждения 300 секундами. Причина — повтор (replay): тот,
кто перехватит утверждение, может отправить его ещё раз. Чем дольше утверждение живёт, тем дольше
открыто это окно. Срок жизни JWT напрашивается мерить от момента, когда он начинает действовать, до
момента, когда перестаёт, — то есть exp - nbf. У утверждения Okta это 600 секунд. Если мерить так,
сервер отклонит каждый настоящий запрос. И отказ будет выглядеть как правильная проверка безопасности.

Поэтому срок жизни мы меряем от момента выпуска (iat) и от текущего момента. А nbf проверяем только
по прямому назначению: раньше этого момента утверждение недействительно. Эту проверку делает
jwtVerify из библиотеки jose ещё до нашего кода:

const { exp, iat, jti } = claims;

if (typeof exp !== 'number' || typeof iat !== 'number')

throw refused('lifetime');

if (typeof jti !== 'string' || !jti) throw refused('identifier');

const now = epochTime();

if (iat > now + clockTolerance) throw refused('issued_in_future');

if (

exp - iat > MAX_LIFETIME_SECONDS + clockTolerance ||

exp - now > MAX_LIFETIME_SECONDS + clockTolerance

) {

throw refused('lifetime');

}

Проверки относительно текущего момента — та половина, которую обычно забывают. Возьмём утверждение,
датированное завтрашним днём, со сроком жизни пять минут. Проверку exp - iat оно проходит без
проблем. Будь проверка только такой, утверждение оставалось бы действительным целые сутки. Поэтому мы
отклоняем iat из будущего и ограничиваем, сколько времени осталось от текущего момента до exp. Так
дыра закрыта. А часам провайдера мы при этом доверяем только в одном: в какую сторону идёт время.

Проверки зашиты в код: проверку, которую можно выключить, кто-нибудь выключит и забудет

Эндпоинт, который закрывает сессии, — это рычаг: с его помощью можно отключить сотрудников целой
компании. Чтобы потянуть за этот рычаг, атакующему нужны три вещи: issuer провайдера (значение iss),
наш client id у этого провайдера и subject одного пользователя там. Ничто из этого не секрет. Поэтому
каждая проверка здесь написана так, будто запрос прислал атакующий. Заметная доля запросов, которые
сюда когда-нибудь дойдут, такими и будет.

Я всё время помню про CVE-2026-18569 — августовскую уязвимость в Keycloak. Получая back-channel logout,
Keycloak принимал logout-токены с alg: none, то есть вовсе без подписи. Так было у любого провайдера,
для которого оператор отключил проверку подписи. Вряд ли кто-то отключает её в продакшене нарочно.
Подозреваю, что её отключают один раз — отладить провайдера, который никак не подключается, — и
забывают. Поэтому у нас набор допустимых алгоритмов подписи — константа. В нём только асимметричные
алгоритмы, и из них — только те, что объявляет сам провайдер. Расширить этот набор не может ни одна
настройка: ни провайдера, ни бакета, ни всего экземпляра. none и все HS* отклоняются. HS* —
симметричные алгоритмы: кто проверяет подпись, должен держать тот же ключ, которым она сделана. А ключ,
которым подписаны эти токены, не должен быть ни у кого, кроме провайдера.

Остальные проверки устроены так же: каждая — константа, и ни одну нельзя настроить.

Провайдер ищется по issuer и client id как по двум отдельным значениям, а не по одному ключу, склеенному
из обоих. Причина — ошибка Keycloak #42209. Там брокерский logout (выход для пользователей, которые
вошли в Keycloak через внешнего провайдера) ломался у любого провайдера, в псевдониме (alias) которого
была точка.

aud (кому предназначено утверждение) должен в точности совпадать с этим эндпоинтом. exp, iat и
jti обязательны. jti — уникальный номер утверждения, и каждый jti принимается только один раз.

Чтобы помнить, какие jti мы уже видели, мы записываем каждый в пространство имён, составленное из
бакета и провайдера. В конце этого имени стоит разделитель. Он нужен, потому что id записи — это хеш от
пространства имён и jti, склеенных вплотную, без чего-либо между ними. Без разделителя две разные пары
«пространство имён + jti» могли бы склеиться в одну и ту же строку.

Если для провайдера функция не включена, он получает 403. Но только после того, как прошёл
аутентификацию. Поэтому тот, кто аутентификацию не прошёл, из отказа не узнает, для каких провайдеров
функция включена. А каждая попытка с неверными учётными данными засчитывается в строгий лимит запросов с
одного источника (origin). Это тот же счётчик, что у эндпоинта SCIM.

Без паузы между загрузками ключей каждый поддельный запрос бьёт по серверу провайдера

Есть ещё одна ловушка — не в проверках, а в кэше ключей. Библиотека jose по умолчанию загружает набор
ключей провайдера заново не чаще раза в тридцать секунд. Эта пауза называется cooldown. На пути
федеративного входа (когда пользователь входит к нам через внешнего провайдера) наш сервер cooldown
отключает. Причина — тест ротации ключей из набора тестов на соответствие OpenID Foundation (эти тесты
проверяют, что сервер следует стандарту). Он показал: после каждой смены ключей у провайдера (ротации)
входы полминуты не проходят. А защищать там cooldown не от чего. ID-токен мы забираем сами, с эндпоинта
токенов провайдера. Значит, неизвестный kid (идентификатор ключа, которым подписан токен) может прийти
только от самого провайдера.

С запросом на отзыв всё наоборот. Отправить его может кто угодно и с любым kid. А кэш без cooldown на
каждый неизвестный kid заново загружает ключи провайдера. Так любой может натравить наш сервер на
провайдера удостоверений заказчика: каждый поддельный запрос заставляет наш сервер один раз скачать
ключи с сервера провайдера. Поэтому у одного провайдера два кэша ключей. Тот, что для JWT, которые нам
присылают в запросах, cooldown сохраняет:

export function presentedKeySetFor(jwksUri: string): RemoteKeySet {

const existing = presentedSets.get(jwksUri);

if (existing) return existing;

/* (the bound on how many providers are held is left out here) */

/* jose's default 30 s cooldown: a genuine rotation is honoured once per cooldown. */

const created = createRemoteJWKSet(new URL(jwksUri), {

[customFetch]: fetchThroughBoundary

});

presentedSets.set(jwksUri, created);

return created;

}

Цена такая. Настоящую смену ключей в Okta мы учитываем не чаще раза в тридцать секунд. Если внутри
этого окна придёт отзыв, подписанный новым ключом (с новым kid), он будет отклонён. Я бы каждый раз
выбрал этот компромисс, а не эндпоинт, который скачивает ключи по просьбе любого желающего.

Черновик Okta — отдельный модуль над общим ядром, и второй формат получил все проверки готовыми

Ядро под всем этим делает три вещи и ничего не знает об Okta. Оно проверяет подлинность внешнего
провайдера этого бакета. Решает, кого этот провайдер вправе назвать. И закрывает этому человеку доступ.
Модуль global token revocation добавляет только детали: в каком claim (поле токена) лежит наш client
id, какую аудиторию (aud) он ожидает, какие типы токенов принимает, пространство имён для записей о
повторах и какая опция провайдера его включает.

Это разделение окупилось почти сразу. Back-channel logout, который нам присылают Keycloak, Auth0 и
Ping, — второй модуль на том же ядре. Он получил все отказы, описанные выше, и ни один не пришлось
писать заново. Поменять пришлось только одно — где лежит наш client id. В logout-токене он в aud, а в
утверждении Okta — в sub. Это место задаёт сам формат, ядро его не угадывает. Если бы ядро угадывало,
токен мог бы сам выбрать, с каким провайдером его сверять. Back-channel logout уже проверен на
настоящем Keycloak 26.8.0. Если какой-нибудь провайдер когда-нибудь начнёт отправлять событие CAEP
session-revoked, оно станет третьим модулем.

Когда я бы включил эту функцию и почему по умолчанию она выключена

Я бы включил её там, где заказчики используют Okta с Identity Threat Protection и ждут, что Universal
Logout дойдёт до каждого приложения. Identity Threat Protection — это лицензия Okta, по которой Okta и
отправляет Universal Logout. Но и тогда я записал бы переключатель в документации как
экспериментальный. А первую неделю кто-нибудь следил бы за событиями отказов. Во всех остальных случаях
я бы его не включал: ненужный вам эндпоинт — это ещё один путь внутрь.

И я честно сказал бы, в каком всё это состоянии, — как стараюсь сделать здесь. На настоящей
организации Okta это не проверяли: для этого нужна Identity Threat Protection. Тесты воспроизводят
запрос, как его описывает документация Okta, но подписывают его ключами провайдера-заглушки
(поддельного провайдера, сделанного для тестов). Остаются два открытых вопроса. Первый: документация
Okta описывает sub как client_id/appInstanceId. Это может оказаться не тот голый client id, с
которым настроен провайдер. Второй: провайдер может быть настроен на issuer custom authorization server
(отдельного сервера авторизации внутри Okta). Этот issuer может не совпасть с issuer организации,
который приходит в запросе Okta. Оба вопроса решит только настоящий прогон.

У SCIM-части, которая обычно идёт вместе с Universal Logout, свои пробелы. CRUD-тест Okta (проверка,
что пользователей можно создать, прочитать, изменить и удалить) не прогоняли, а эндпоинта /Bulk нет.
На сертификацию ничего из этого не подавали. Если черновик возродят и изменят, модуль изменится вместе с
ним. Если он так и останется мёртвым, Okta, вероятно, перейдёт на что-то другое. А ядро всё равно
останется той частью, которую стоило написать.

Все статьиПопробовать самому
