Как мы прогоняли свой OpenID-сервер через официальный conformance-suite и что он в нас нашёл

redb.Identity

Мы были уверены, что profile-claims в нашем OpenID-сервере отдаются правильно. Юнит-тесты зелёные, демо-прогон зелёный, ручная проверка через curl показывала name, family_name, email, всё на месте. Потом мы прогнали сервер через официальный conformance-suite OpenID Foundation, и он показал, что телефон и адрес пользователя лежат в id_token. А id_token уходит третьим сторонам и пишется в логи как доказательство входа. То есть номер телефона пользователя ездил гораздо дальше, чем клиент вообще запрашивал. Это утечка PII, и никакой наш тест её не ловил, потому что мы не знали, что искать.

Про это и статья: как устроен OIDF conformance-suite, как настроить и подготовить к нему свой сервер, почему проходить его приходится руками через настоящий браузер, и что он в нас нашёл. И почему прогнать такой сьют стоит любому, кто пишет свой identity-сервер, а не только тем, кто идёт за сертификацией.

Зачем вообще внешний арбитр

OAuth 2.1 и OpenID Connect, это не один документ, а стопка RFC и спек, где легко сделать «почти правильно». Токен выдаётся, вход проходит, демо работает, а деталь мимо спеки живёт незаметно, пока об неё не споткнётся конкретный клиент или аудитор. Классика таких деталей:

  • claim попал не в тот токен (id_token вместо userinfo) и утёк, куда не следовало;
  • userinfo отдаёт лишние поля, потому что фильтрует по deny-list, а не allow-list;
  • discovery-документ обещает фичу, которой на эндпоинте нет, или наоборот;
  • authorization code принимается повторно из-за гонки в single-use.

Своими тестами это не поймать по простой причине: тест проверяет то, что ты знаешь. Спека большая, знать всё наизусть невозможно, и слепые пятна остаются именно там, где ты уверен, что всё хорошо. Conformance-suite, это внешний арбитр, который читает спеку за тебя и гоняет сервер по её букве, а не по твоим ожиданиям.

OpenID Foundation держит официальный conformance-suite, тот самый, на котором проходит настоящая сертификация. Его можно поднять локально и прогнать свой сервер бесплатно, без всякой заявки на сертификацию. Именно это мы и сделали.

Как настроить сьют

Сам сьют, это Java-приложение в докере: сервер тестов, nginx и MongoDB. Он выступает в роли клиента (Relying Party): регистрирует у вашего OP приложения, гоняет флоу, инспектирует ответы и сверяет их со спекой. Поднимается штатным compose:

docker compose -f conformance/docker-compose.yml up -d   # server + nginx + mongodb

UI открывается на https://localhost.emobix.co.uk:8443 (домен резолвится в localhost, сертификат сьют приносит свой). Дальше нужен план, а не отдельные тесты. Мы генерируем его скриптом, который:

  1. проверяет, что discovery вашего OP отвечает и issuer тот, что нужно;
  2. регистрирует через DCR несколько клиентов (про количество ниже);
  3. создаёт план профиля oidcc-basic-certification-test-plan с вариантом server_metadata=discovery, client_registration=static_client;
  4. печатает ссылку на план в UI.

Три момента в этой настройке неочевидны, и каждый стоил времени.

Issuer и реальный HTTPS. Сьют требует, чтобы issuer в discovery совпадал с адресом, по которому он ходит, и работал по HTTPS. Мы поднимали OP на https://host.docker.internal:5002 с самоподписанным сертификатом: сьют из докера ходит на хост по этому имени, а редиректы возвращаются на его собственный callback. Если issuer в вашем discovery не host.docker.internal, план не создастся, скрипт это проверяет первым делом.

Три клиента в плане, а не два. Модуль oidcc-server-client-secret-post внутри переопределяет client значением из ключа client_secret_post. Если такого клиента в конфиге плана нет, тест падает до первого запроса к OP с сообщением «нет клиента», хотя клиент вроде бы есть. Мы это раскопали, декомпилировав jar сьюта: в плане нужен третий клиент, зарегистрированный под этим ключом.

Полный профиль тестового пользователя. Тесты oidcc-scope-* логинятся живым пользователем и сверяют, что userinfo вернул все claims, положенные scope. Если у пользователя не заполнены birthdate, zoneinfo, address и прочие поля OIDC §5.1, сьют выдаёт WARNING на каждое отсутствующее. Пользователь для прогона должен быть заполнен целиком, у нас это делает отдельный seed-скрипт.

Почему руками, а не скриптом

У сьюта есть встроенный «браузер» (HtmlUnit), который сам ходит по authorize-ссылке. На бумаге это позволяет прогнать план автоматически. На практике он давится на Bootstrap 5.3.3, который собственная callback-страница сьюта тянет с CDN: обработка страницы падает, callback не постится обратно, тесты висят в WAITING вечно.

Поэтому мы гоняли план вручную через настоящий Chrome, и это, кстати, ровно то, как проходит реальная сертификация OIDF. Механика простая: план не содержит секции browser, поэтому сьют для каждого теста показывает authorization-ссылку и ждёт живого визита. Логинишься один раз, дальше сессия живёт в браузере, и последующие тесты редиректят насквозь. Несколько тестов (prompt=login, max_age) специально просят пароль повторно, так и задумано.

Прогон идёт по одному тесту за раз: все тесты в плане делят один callback-alias, и запуск следующего до того, как предыдущий дошёл до FINISHED, убивает предыдущий.

Что сьют в нас нашёл

Вот здесь начинается интересное. Первая версия нашего разбора списывала несколько непрошедших модулей на «ограничения харнесса» и «конфигурацию сьюта». Когда мы перестали отмахиваться и разобрались, оказалось, что почти всё, это наши реальные дефекты. Показательно перечислить их прямо.

PII в id_token. Модуль oidcc-server показал, что scope-производные claims (profile, email, phone, address) зашивались в id_token. В authorization-code flow они должны отдаваться из UserInfo (OIDC §5.4), а не из id_token. Разница принципиальная: id_token пересылается третьим сторонам и логируется как доказательство входа. Телефон и адрес пользователя уезжали дальше, чем клиент запрашивал. Починка: scope-производные claims теперь имеют destination только AccessToken, их отдаёт /connect/userinfo, а в id_token их нет.

UserInfo с лишними полями. UserInfo копировал claims через deny-list и утаскивал внутреннюю кухню токена: oi_*-поля OpenIddict, jti, exp, iat, at_hash. Всё это описывает токен, а не пользователя (§5.3). Deny-list по определению дырявый: забыл что-то запретить, и оно утекло. Заменили на явный allow-list.

Неполный набор profile-claims. oidcc-scope-address/phone/all выдавали WARNING, и это была не «клиент не с теми scope». Сьют сверяет userinfo с точным списком OIDC §5.1 и предупреждает про каждый отсутствующий claim. Мы отдавали неполный набор. Починка: полный §5.1, с updated_at как JSON-число и *_verified как JSON-булевы, типы тоже проверяются.

claims-параметр отсутствовал. oidcc-claims-essential просит name как essential claim через параметр claims (§5.5). Мы его не реализовали вообще, и наше же discovery честно говорило claims_parameter_supported: false. Реализовали §5.5, discovery теперь говорит true.

UserInfo не принимал токен в теле. oidcc-userinfo-post-body: POST на /connect/userinfo не принимал access-token в form-encoded теле (RFC 6750 §2.2). Поправили маппинг формы в тело.

Обратите внимание на природу этих багов. Ни один из них не роняет вход. Всё работает, демо зелёное, пользователь заходит. Дефект в том, куда уехал claim, и в том, что discovery обещает не то, что делает эндпоинт. Такое ловит только тот, кто читает спеку построчно. У нас читал сьют.

Итог прогона и честный разбор «не-PASSED»

Basic OP profile, 35 модулей, 0 failures. Разбивка: 29 PASSED, 3 REVIEW, 1 WARNING, 2 SKIPPED. Про последние три категории стоит сказать прямо, потому что для несведущего человека SKIPPED и WARNING в отчёте выглядят как провал, а это не так.

3 REVIEW, это тесты, где сьют требует загрузить скриншот экрана как доказательство (prompt-login, max-age, ensure-registered-redirect-uri). Сервер отрабатывает их корректно end-to-end, сьют просто дополнительно хочет визуальное подтверждение и потому не финиширует их автоматически. Pass-equivalent.

1 WARNING, это oidcc-server предупреждает про два claim в id_token, которые мы осознанно туда кладём (внутренние oi_tkn_id и redb:user_id для нужд самого сервера). Это наше решение, а не дефект.

2 SKIPPED, вот тут важно. Оба скипнутых модуля тестируют unsigned (alg:none) request object, то есть JWT без подписи. Сьют сам их скипает, если сервер не объявляет поддержку alg:none в request_object_signing_alg_values_supported. Мы её не объявляем осознанно: alg:none выбрасывает ровно ту гарантию целостности, ради которой JWT-Secured Authorization Request (RFC 9101) и существует, и FAPI 2.0 его прямо запрещает. SKIPPED здесь означает «сервер отказался рекламировать небезопасный режим», это правильный ответ, а не пропущенная фича. Убрать этот skip можно только начав принимать alg:none, то есть сделав регресс по безопасности.

Так что читать бейдж стоит так: два скипа, ноль провалов, и оба скипа, это сервер, отказывающийся от небезопасного режима, который профили безопасности велят отклонять.

Что за этим стоит: своя проверка

Conformance-suite, это внешний арбитр, но гонять его руками на каждый коммит нереально. Поэтому под ним лежат два своих слоя, которые ловят регрессы автоматически.

Юнит- и интеграционные тесты, 1824 штуки, зелёные на трёх провайдерах (PostgreSQL, SQL Server, SQLite). Это не только «токен выдался»: там около 38 областей, от протокольных флоу до кухни хранилища. TokenFlow (introspection, revocation, refresh-rotation, device code, single-use кода), FullStack (сквозные HTTP-прогоны authorize→token→userinfo), Scim (провижининг пользователей и групп, bulk, ETag), Federation (внешние OIDC-провайдеры), Dpop, Consent, MFA, DataProtection, TxIntegrity (атомарность под конкурентностью). Каждая находка сьюта после починки закрывается тестом, чтобы не вернулась.

Демо-прогон, 62 пробы по живому серверу. Это отдельный слой, и у него другая роль: не юнит-проверка в изоляции, а RFC-пробы по поднятому OP через настоящий HTTP. authcode_pkce, device_code, client_credentials, token_exchange, dpop, par, jar_request_object, private_key_jwt, mfa_totp, scim_enterprise, backchannel_logout, throttle_rfc6585 и так далее. Здесь важен принцип: упавшая демка, это баг сервера, а не повод расширить список допустимых кодов ответа. Демо-проба воспроизводит поведение, которое ждёт спека; если сервер отвечает не так, чинится сервер.

Разделение слоёв осмысленное. Юнит-тесты быстрые и ловят логику. Демки проверяют то, что видно только на живом сервере через HTTP: заголовки, коды, формат discovery. Сьют, это внешний арбитр по букве спеки. Каждый слой ловит то, что пропускают остальные.

Почему это стоит сделать любому

Если вы пишете свой OpenID/OAuth-сервер, прогоните его через OIDF conformance-suite локально, даже если сертификация вам не нужна. Причина простая: claim в неправильном токене или discovery, обещающий не то, это не теория. Мы отдавали телефон пользователя в id_token и были уверены, что всё правильно, пока внешний арбитр не показал обратное. Свои тесты этого не находили, потому что мы не знали, что это баг.

Сьют бесплатный, поднимается в докере, и один прогон Basic OP занимает вечер. Взамен вы получаете построчную сверку своего сервера со спекой человеком, который её действительно прочитал, машиной OpenID Foundation. Для identity-сервера, которому доверяют вход пользователей, это несопоставимо дешевле, чем узнать про утечку от того, кто нашёл её не с добрыми намерениями.

Отдельно про честность отчёта. SKIPPED и WARNING в conformance-выводе не обязательно провал; часто это ровно то место, где сервер правильно отказался делать небезопасное. Читайте не цвет строки, а её причину.

Мы не носим марку OpenID Certified™, это товарный знак, выдаётся Фондом по отдельной процедуре. Речь именно про локальный прогон официального сьюта: он честно показывает, где сервер расходится со спекой, и это уже огромная ценность независимо от формальной сертификации.

Другие мои статьи: redb.ru/articles, ещё на Хабре.