Блог
Что Entra и Okta на самом деле шлют SCIM-серверу
SCIM-сервер, который строго следует профилю, отклоняет то, что на деле присылают Entra и Okta. Какие это запросы и почему хватает одной настройки.
Автор: FoxAuth ·
Компания ведёт своих сотрудников в каталоге — например, в Okta или Microsoft Entra ID. Каталог сам заводит, меняет и отключает учётную запись каждого человека во всех приложениях компании. Это называется провижинингом. SCIM — протокол, по которому каталог это делает.
Вот как Okta отключает (деактивирует) пользователя по SCIM. Запрос ровно в той форме, которую
описывает справочник самой Okta. Он адресован одному из наших бакетов пользователей — acme в пути.
Бакет — так наш сервер называет группу пользователей со своими правилами входа. Когда у бакета
появляется свой адрес, он выпускает токены от своего имени, то есть становится отдельным издателем
(issuer).
PATCH /acme/scim/v2/Users/2819c223-7f76-453a-919d-413861904646 HTTP/1.1Host: auth.example.comAuthorization: scimst_q3TqC1lG0iJ...Content-Type: application/scim+json
{ "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "Operations": [{ "op": "replace", "value": { "active": false } }]}В запросе нет path — поля, которое говорит, какой атрибут менять. Вместо этого Okta кладёт само
изменение в value: поставить active в false. RFC 7644, стандарт SCIM, так делать разрешает.
Но есть ещё профиль совместимости SCIM 2.0. Профиль — это документ, который сужает стандарт, чтобы
разные реализации понимали друг друга. Этот профиль требует, чтобы сервер отклонял PATCH-операцию
(изменение части записи) без path. Мы ориентируемся на профиль OpenID IPSIE SCIM (Interoperability
Profiling for Secure Identity in the Enterprise), а он построен поверх того профиля. У строгого
правила есть веская причина. Все неясные случаи в правилах изменения записей SCIM живут именно в
операциях без path. Поэтому сервер, который делает то, что требует профиль, ответит на этот запрос
400. А человек, уволившийся вчера, останется активным.
Я внимательно прочитал оба профиля ещё до того, как написал первую строчку сервера. И всё равно думал, что самое интересное — им следовать. Оказалось, нет. Большинство заказчиков пользуются одним из двух каталогов — Entra или Okta. И ни один из них не может управлять пользователями на сервере, который буквально следует этим профилям. Так что полезнее знать другое: где именно каждый из них от профилей отступает.
Okta отключает пользователя запросом, который профиль запрещает
Отступления клиента от профилей я называю его диалектом. Диалект Okta невелик, поэтому его легко
недооценить. Важны две формы. Первая — replace без path, показанный выше. Так Okta отключает
пользователя и включает его обратно. А это самое важное, что вообще делает клиент провижининга.
Вторая — атрибут password, когда Okta создаёт пользователя.
IPSIE прямо запрещает password в схеме пользователя. Причина — федерация: приложения не хранят
своих паролей, а отправляют пользователя входить через каталог. Каталог, который рассылает пароли во
все приложения, — ровно то, от чего федерация должна была избавить. Да и наши пользователи всё равно
входят через каталог.
То, как Okta доказывает, кто она, тоже расходится с профилями, и это удивляет больше всего. Получить
токен для SCIM через OAuth client credentials Okta не умеет вообще. Client credentials — это способ,
которым программа в OAuth сама, без пользователя, получает токен доступа. Вместо этого Okta отправляет
статический токен — постоянную строку, одну и ту же в каждом запросе. В режиме заголовка Okta кладёт
этот токен в Authorization целиком, без слова Bearer впереди.
Поэтому наши статические токены начинаются с scimst_. У префикса две задачи. По нему сервер
отличает такой токен от токена доступа, ничего не ища в хранилище, и принимает его без Bearer. А
сканер секретов по нему узнаёт токен, утёкший в репозиторий. Сервер хранит только SHA-256-дайджест
токена — отпечаток, по которому сам токен восстановить нельзя. А показывает токен один раз.
Entra отступает от профилей ещё сильнее, и Microsoft сама признаёт это отдельным флагом
У Microsoft Entra ID отступлений больше, и они страннее:
- имя операции с заглавной буквы:
"op": "Replace", хотя в RFC 7644 имена операций строчные; - true и false строкой:
"value": "False"; replaceбез path, который меняет сразу несколько атрибутов. Ключи в нём — имена вложенных атрибутов через точку и полные имена (URN) атрибутов из расширений схемы:name.givenNameрядом сurn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department;- атрибут
addressesв сопоставлении атрибутов по умолчанию — настройках, которые решают, какие поля пользователя Entra отправляет. В нашей схеме пользователя такого атрибута нет; replace, в path которого стоит фильтр, под который ничего не подходит. Например,emails[type eq "work"].value(рабочий адрес из списка email пользователя) для пользователя, у которого рабочего email ещё нет. По RFC 7644 сервер должен ответитьnoTarget. А Entra имеет в виду «добавь»;- и корпоративный атрибут
manager. Entra шлёт его голым id,"value": "mgr-1", хотя по схеме нужно{ "value": "mgr-1" }. А чтобы очистить его, Entra шлёт неremove, аreplaceс пустой строкой.
Большую часть этого списка мы взяли из документации Microsoft и из того, что пишут другие
разработчики SCIM-серверов. Две формы manager — нет, и они нравятся мне меньше всего. Мы нашли их,
только когда наш сервер провалил тесты Add, Replace и Remove Manager в SCIM-валидаторе самой
Microsoft — её наборе тестов для SCIM-серверов. Это поведение нигде не было описано. Обнаружил его
тест на соответствие от самого вендора.
Microsoft документирует флаг aadOptscim062020. Администратор Entra дописывает его к tenant URL —
адресу SCIM-эндпоинта приложения, который он вводит в Entra. С этим флагом Entra переводит часть
своего старого поведения на форму, которую требует стандарт. True и false строкой — в их числе. Более
явного признания, что диалект Entra существует, я не знаю. Но сервер не может рассчитывать, что
каждый заказчик этот флаг добавил.
Вот вся картина целиком. Строгий сервер следует профилям буква в букву. Нестрогий принимает эти формы и делает то, что клиент имел в виду. Каждую строку читайте как предложение: этот клиент шлёт вот это, строгий сервер это отклоняет, а нестрогий вместо этого делает вот что.
В левом столбце нет ничего редкого. Это повседневный трафик двух клиентов, на которых приходится большая часть корпоративного рынка. Сервер, который отвечает как в столбце «Строго», соответствует профилям. Только пользоваться им не может никто.
Мы выбросили готовые библиотеки: они отвергали обычные запросы и пропускали опасные
Для самых сложных частей мы собирались взять две популярные библиотеки. Одна разбирает SCIM-фильтры — условия, по которым каталог ищет пользователей. Другая применяет PATCH к сохранённой записи. Обе мы выбросили. И то, почему выбросили, полезнее, чем то, чем заменили.
Строки в кавычках парсер фильтров разбирал шаблоном с перебором вариантов (backtracking). Если ему попадалась серия переводов строки без закрывающей кавычки, время разбора росло экспоненциально с длиной этой серии. А фильтр приходит от каталога в строке запроса. Значит, любой обладатель SCIM-токена мог прислать один такой фильтр и надолго занять сервер. Это отказ в обслуживании (DoS).
С библиотекой патчей проблем было больше. Она отклоняла REPLACE. На remove без path бросала
TypeError. "False" сохраняла как строку "False", а не как false. Имена атрибутов сравнивала с
учётом регистра, хотя в SCIM регистр не важен. И в этом году на неё вышли два бюллетеня безопасности
о prototype pollution. Это когда ключ вроде __proto__ во входных данных меняет свойства, общие для
всех объектов программы. Выходит, библиотека была строга ровно к тому, что делают реальные клиенты. И
снисходительна к единственному по-настоящему опасному: к ключам вроде __proto__ в теле запроса,
которое пишет чужая система.
Реальные клиенты используют лишь небольшую часть языка фильтров и патчей SCIM, поэтому и наши замены
вышли небольшими. Новый парсер фильтров читает строку один раз, от начала до конца, и принимает только
фильтры из списка IPSIE. Property-тест (он прогоняет код на множестве случайных входов) проверяет, что
время разбора растёт пропорционально длине входа, не быстрее. Новый код патчей сверяет каждый путь с
таблицей атрибутов, которые мы объявили. Он отклоняет __proto__, constructor и prototype в
любом месте тела. А каждое послабление — то есть каждая форма, которую код принимает только потому,
что её шлёт реальный клиент, — спрятано за одно и то же условие, leniency.strict:
const op = opText.toLowerCase();if (op !== 'add' && op !== 'replace' && op !== 'remove') { throw new ScimError( 400, 'invalidSyntax', `${opText} is not a PATCH operation` );}if (leniency.strict && opText !== op) { throw new ScimError(400, 'invalidSyntax', 'the op must be lower case');}
if (path === undefined || path === '') { if (op === 'remove') throw new ScimError(400, 'noTarget', 'remove needs a path'); /* Okta's {"op":"replace","value":{"active":false}}; Entra's multi-attribute replace. */ if (leniency.strict) { throw new ScimError(400, 'invalidSyntax', 'an operation needs a path'); } if (!isObject(value)) { throw new ScimError( 400, 'invalidValue', 'a path-less operation needs an object value' ); } for (const [key, v] of Object.entries(value)) { applyOne(working, op, key, v, leniency, resource); } continue;}Две вещи в этом коде сделаны намеренно. Первая: принять форму — обычный путь, а отказ — исключение.
Ведь обычный трафик и есть диалект Entra и Okta. Вторая: remove без path отклоняется в любом
режиме. Нестрогому серверу пришлось бы тут спросить: «удалить — что?» А угадывать здесь опасно:
именно так каталог и стирает пользователю весь профиль.
Одна настройка strict: сервер либо следует профилям целиком, либо принимает Entra и Okta
У каждой из этих форм могла быть своя настройка. Какое-то время в проекте их и было несколько. В
итоге осталась одна — scim.strict, по умолчанию выключена. Я считаю, что одна — это правильно, и
дело тут в операторе (том, кто разворачивает и настраивает сервер), а не в коде. Оператор решает
только один вопрос. Я сертифицирую этот сервер на соответствие профилям? Или подключаю к нему Entra и
Okta?
Сервер, у которого включена только часть послаблений, профилям не соответствует. Поэтому панель
отдельных переключателей даёт десятки комбинаций. И все, кроме двух (всё включено или всё
выключено), не годятся ни для одной из этих целей. Когда scim.strict включена, на каждую форму из
таблицы приходит 400. Только тогда /ServiceProviderConfig — эндпоинт, где SCIM-сервер
описывает, что он умеет, — объявляет interopProfileConformant. Сервер, который принимает хотя бы
одну из этих форм, не вправе этого заявлять.
С учётными данными — то же правило: один переключатель на каждое решение, которое оператор
действительно принимает. Здесь таких решений два. Entra получает токен на эндпоинте токенов бакета
по секрету клиента. Секрет она шлёт через Basic-аутентификацию (в заголовке Authorization) или в
теле запроса — как выберет её администратор. Пока workload identity federation (способ входа
программ без хранимого секрета) не дошла до провижининга, ничего, кроме секрета, Entra отправить не
может. Okta шлёт свой статический токен.
Ни то ни другое IPSIE не устраивает. Профиль хочет client assertion — подписанный JWT, которым
клиент доказывает, кто он. У секрета Entra и у статического токена Okta по своей настройке:
scim.secretCredentials и scim.staticTokens. Обе по умолчанию включены.
Оператор, которому нужен только способ из профиля, может выключить обе и посмотреть, что именно
перестанет работать.
У пути через эндпоинт токенов есть одно свойство, от которого я бы не отказался. Каталог связан с
нашим сервером подключением провижининга — записью, которая связывает один каталог с одним бакетом.
Из этого подключения сервер каждый раз, когда нужно, собирает OAuth-клиент scim-<connection id>.
Сам клиент нигде не хранится. Поэтому SCIM получает весь эндпоинт токенов даром. В том числе защиту
от того, что один и тот же client assertion используют дважды, и сравнение секретов за постоянное
время. Такое сравнение длится одинаково, насколько бы близкой ни была догадка, так что по времени
ответа ничего не узнать. Сменить учётные данные — это одна запись в подключение. Никакой скрытой
копии, которую пришлось бы держать в согласии, нет.
Токен, который выдаёт этот эндпоинт, — opaque-токен: случайная строка без содержимого, о которой
знает только наш сервер. Он несёт scope scim и ничего больше. Его аудитория — SCIM URL одного
бакета, так что в другом месте он не годится. И он перестаёт действовать, как только учётные данные
перевыпустят.
Раздел 4.1 IPSIE требует учётные данные в теле запроса и тут же запрещает их там
Было бы удобно, если бы требования профиля хотя бы не противоречили друг другу. Тогда
соответствовать ему было бы всего лишь непопулярно. Но они противоречат. Раздел 4.1 профиля IPSIE
SCIM требует grant client credentials «with JWT Client Authentication as defined in RFC7523 section
2.2». То есть клиент доказывает, кто он, через client assertion. Несколькими строками ниже тот же
раздел требует «HTTP Basic authentication in the Authorization request header». И добавляет, что
передавать учётные данные клиента, такие как client_assertion или client_secret, в теле запроса
запрещено.
Но RFC 7523 §2.2 передаёт assertion как раз в теле запроса — именно это раздел и определяет. А Basic передаёт общий секрет, в заголовке. Так что ни один клиент не может выполнить оба требования. И ни один сервер не может подсказать клиенту, какое из них нарушить. Мы следуем требованию про JWT, по двум причинам: его повторяет §10, а в §4.1 есть примечание редактора, что раздел нужно расширить. И мы прямо пишем об этом в наших заметках о соответствии, а не выбираем молча. Профиль — draft 00, первый черновик. Его исправят. А пока не исправили, тот, кто говорит вам, что его сервер полностью соответствует §4.1, не прочитал этот раздел дважды.
Что нашли тесты Microsoft и Okta — и что мы ещё не проверяли
SCIM-валидатор Microsoft — это набор тестов Microsoft для SCIM-сервера. Мы прогоняем его на нашем стенде для проверки соответствия — копии сервера, которую держим для таких тестов. Сервер проходит 23 обязательных теста из 24 и все семь предварительных (preview), включая группы. Единственный провал — «Patch User - Replace Attributes». Валидатор пишет, что у пользователя нет основного email и номера телефона. Но ответ на PATCH, который валидатор сам же показывает, содержит и то и другое. И если потом прочитать пользователя, вернутся оба. Другие разработчики сообщают о точно таком же провале на правильных ответах, а Microsoft им не отвечает. Я считаю этот провал ошибкой валидатора. Доводы изложены в наших заметках о соответствии, так что любой может с ними поспорить.
Набор тестов Okta называется SCIM 2.0 Spec Test. В нём наш стенд проходит все одиннадцать
обязательных тестов и необязательный тест групп — 53 проверки из 53. Уже первый прогон этого теста
нашёл у нас настоящую ошибку. Запрос на адрес под базовым адресом SCIM, который сервер не
обслуживает, получал 404. Но в теле ответа было server_error — код сбоя внутри сервера.
Не менее важно, что мы не прогоняли. CRUD-тесту Okta (создание, чтение, изменение и удаление
пользователей) нужна организация Okta с установленной нашей интеграцией. Его мы не запускали.
Эндпоинта /Bulk (много операций в одном запросе) у нас нет. Ни Entra, ни Okta его не вызывают. Он
запланирован на потом, вместе с асинхронными bulk-операциями. Но профиль его перечисляет, так что без
него профиль выполнен не полностью. Universal Logout от Okta вызывает наш эндпоинт global token
revocation, который отзывает токены пользователя. Его тоже ни разу не проверяли на настоящей
организации Okta. И на сертификацию мы ничего из этого не подавали.
С Entra и Okta оставьте strict выключенным; включайте его для сертификации и проверки своего клиента
Если вы подключаете Entra ID или Okta, оставьте scim.strict выключенным. Это не ослабление вашего
сервера, а просто факт о клиентах. У каждой формы, которую сервер при этом принимает, есть границы.
Атрибут сверяется с таблицей атрибутов, которые мы объявили. Необъявленный атрибут игнорируется, а не
сохраняется. А опасные входные данные — ключи вроде __proto__ и remove без path — отклоняются в
обоих режимах.
Включайте strict для прогона сертификации. Или когда единственный клиент — ваш собственный,
написанный по профилю. Тогда результат — это тест для этого клиента. Если ваш клиент не проходит
строгий режим, это баг, и вы можете его исправить. Про manager в Entra такого не скажет никто. А
если вы пишете SCIM-сервер с нуля, начинайте с таблицы выше, а не с RFC. RFC говорит, что разрешено.
Таблица говорит, что придёт в три часа ночи, когда каталог заказчика запустит первую полную
синхронизацию.