
redb.Identity получил WS‑Trust фасад: Issue, Validate, Cancel, Renew на тех же маршрутах ядра. Тот же JWT, тот же реестр клиентов, WSDL для генератора.
Когда мы делали gRPC‑фасад, тезис был такой: логика redb.Identity живёт в ядре за адресами direct-vm://identity-*, а транспорт это тонкий переводчик поверх них. Второй фасад тезис подтвердил. Но два транспорта, оба современные, оба построенные примерно на одних представлениях о мире, проверяют утверждение мягко. По‑настоящему проверяет третий, устроенный принципиально иначе.
Теперь он есть. Рядом с HTTP и gRPC встал WS‑Trust: Issue, Validate, Cancel, Renew по SOAP, на те же маршруты ядра, с тем же издателем и тем же хранилищем токенов. Токен, выданный по SOAP, принимается по HTTP и получает там тот же вердикт.
Кому это нужно
Есть контуры, где SOAP не «наследие», а текущая норма. Банковский бэк‑офис, страховой, промышленный, государственный. Там стоят шины на WCF и CXF, интеграции сгенерированы из WSDL, а команда, которая их сопровождает, умеет отлаживать конверты и не умеет отлаживать OAuth‑редиректы. Просить у такого контура «просто сходите за токеном по HTTP с form‑encoded телом» означает просить его написать и сопровождать код, для которого у него нет ни инструментов, ни привычек.
WS‑Trust как раз и есть OAuth этого мира. Он старше, он многословнее, он про XML, но задача у него ровно та же: клиент приходит со своими учётными данными и уносит токен. Если сервер авторизации умеет говорить на нём, интеграция перестаёт быть проектом и становится генерацией клиента из WSDL.
Выглядит это со стороны потребителя так:
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"> <soap:Header> <wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"> <wsse:UsernameToken> <wsse:Username>billing-service</wsse:Username> <wsse:Password>...</wsse:Password> </wsse:UsernameToken> </wsse:Security> </soap:Header> <soap:Body> <wst:RequestSecurityToken xmlns:wst="http://docs.oasis-open.org/ws-sx/ws-trust/200512"> <wst:RequestType>http://docs.oasis-open.org/ws-sx/ws-trust/200512/Issue</wst:RequestType> </wst:RequestSecurityToken> </soap:Body> </soap:Envelope>
Ни слова про OAuth. Внутри же это обычный client_credentials к тому же ядру, что обслуживает HTTP.
Что именно появилось
Четыре операции WS‑Trust, разложенные на маршруты ядра:
Операция | Куда идёт | Что означает |
|---|---|---|
|
| выдать токен |
|
| обновить по refresh‑токену |
|
| проверить, живой ли |
|
| отозвать |
Учётные данные клиента приезжают в UsernameToken и переводятся в client_id и client_secret, то есть в те же поля, которые ядро принимает от всех остальных. Отдельного пути аутентификации у фасада нет, и это принципиально: второй путь означал бы второе место, где решается, кого пускать.
Одна деталь ломает привычную по gRPC форму. Там каждая операция это свой адрес, и маршрутизация бесплатна. WS‑Trust кладёт все четыре на один адрес и различает их по заголовку WS‑Addressing Action. Сгенерированные клиенты ждут именно этого, поэтому операция определяется разбором запроса, а не адресом, на который он пришёл.
Что кладём в RSTR
Наш обычный JWT, как есть, в wsse:BinarySecurityToken с типом, объявляющим JWT.
Это решение, а не умолчание, и принималось оно до кода. Альтернатива была собирать SAML‑assertion: своя модель утверждений, свои условия, своя подпись XML. Разница не в объёме написания, а в том, что в системе появился бы второй формат токена, и его пришлось бы провести через отзыв и интроспекцию, которые сейчас знают один.
Мы выбрали начать с JWT и вернуться к SAML тогда, когда появится клиент, который его требует, а не под предположение о рынке. Цена названа честно: часть клиентов WS‑Trust ждёт именно SAML и с JWT работать не станет. Для них фасад закрывает не всю нишу, а её часть.
Зато сразу работает то, что иначе стоило бы недель: Validate и Cancel действуют на токен, выданный по SOAP, потому что это буквально тот же объект, что ходит по HTTP.
Отказ приходит фолтом, а не телом
Клиент, которому ответили «успех», не имеет причин заглядывать внутрь ответа. Поэтому отказ ядра обязан приезжать soap:Fault со своим кодом: сгенерированные WS‑Trust клиенты ветвятся именно по коду, и FailedAuthentication для них не то же самое, что InvalidRequest.
Перевод устроен так, чтобы не стать третьей независимой копией. Чтение вердикта ядра живёт один раз, в общем IdentityVerdict, а у SOAP остаётся только отображение вердикта в свой словарь:
Вердикт ядра | Фолт |
|---|---|
|
|
|
|
|
|
остальное |
|
WS‑Trust называет заметно меньше исходов, чем HTTP, поэтому несколько вердиктов ложатся на один фолт. Там, где так вышло, текст причины несёт то, чего не несёт код, и поэтому он никогда не выбрасывается.
Отдельно про RateLimited. Кода для «подождите» в WS‑Trust нет, и soap:Server тут ближе из двух доступных прочтений: отказ наш, а не клиента, и переписывать запрос ему незачем.
Главная проверка: один токен, два транспорта, один вердикт
Всё остальное можно списать на аккуратную реализацию. Это списать нельзя.
Клиент регистрируется по HTTP через /connect/register. Токен берётся по SOAP. Затем этот токен предъявляется обратно по HTTP на административной ручке, и она его принимает. И в другую сторону: клиенту с правом только на чтение отказывают в записи оба транспорта.
Это и есть смысл слова «фасад». Не «SOAP‑совместимый сервер авторизации рядом с основным», а тот же самый сервер, к которому есть третья дверь.
Границы, названные заранее
Браузерных потоков в фасаде нет: authorize, экран согласия, MFA, подтверждение устройства. Причина не про SOAP, им нужен браузер, редиректы и cookie‑сессия. У этой ниши свой ответ, WS‑Federation, и он вынесен как отдельное решение, а не как недоделка.
DPoP тоже нет: RFC 9449 привязывает доказательство к HTTP‑методу и URL.
Административной поверхности в первой версии нет. Она есть у gRPC, а контуру, который приходит за WS‑Trust, обычно нужна выдача токенов, а не управление пользователями через SOAP.
TLS здесь обязателен, и фасад не поднимется без него
UsernameToken в его обычной форме передаёт пароль клиента открытым текстом. Значит STS на голом HTTP публикует учётные данные всем, кто стоит на пути.
Мы не стали писать про это предупреждение в лог. Предупреждение читают потом, отказ читают сразу, поэтому фасад просто не стартует:
redb.Identity.Soap refuses to start without TLS: WS-Security UsernameToken carries the client secret in clear text.
Выход ровно один и назван явно: AllowPlaintext, для запуска за прокси, который уже терминировал TLS. Это заявление оператора о том, что провод защищён другим способом, а не способ обойти проверку. Ставить его молча не получится.
Сертификат клиента поддержан на уровне TLS, режимами и пиннингом по отпечатку, так же как у gRPC‑фасада.
WSDL отдаётся на GET
Аудитория этого фасада строит клиентов генератором, а генератору нужен документ. GET на тот же адрес отдаёт WSDL с soap:address, переписанным на URL, по которому его забрали. Дальше dotnet-svcutil, svcutil или wsimport делают своё дело.
STS, отвечающий только на POST, для этой аудитории просто непригоден.
Как посмотреть за десять секунд
Ставить нечего: SOAP это обычный HTTP с XML‑телом, поэтому демо написано на голом Invoke-WebRequest и печатает всё, что уходит и приходит.
pwsh -File demos/demo_soap_facade.ps1
Десять шагов: WSDL, регистрация клиента по HTTP, Issue по SOAP, Validate, Cancel, повторный Validate уже с отрицательным ответом, отказ на неверном секрете с кодом фолта, анонимный запрос, мусорное тело с wst:InvalidRequest, и в конце токен, выданный по SOAP и потраченный по HTTP.
Чем это покрыто
Двадцать восемь тестов на самом фасаде: разбор RST, перевод в параметры ядра, таблица фолтов, сквозные прогоны через живой слушатель в настоящее ядро, отдельно проверка того, что ограничение частоты по IP за фасадом действительно работает.
Последнее стоит пояснить. Ядро ключует ограничение частоты, блокировку после неудачных входов и метаданные устройства по адресу клиента. При отсутствии адреса эти проверки не падают, а тихо ничего не делают, то есть выглядят как «злоупотреблений нет». Поэтому мало было пробросить адрес, надо было увидеть, что лимит за фасадом реально срабатывает.
Что пришлось починить в самом коннекторе
Фасад строится на redb.Route.Soap, и работа над ним вскрыла в коннекторе три дыры. Все три общие, не про Identity.
Маршрут не мог выбрать код фолта. Консюмер брал из исключения только текст, а код всегда ставил soap:Server. То есть любой отказ по вине клиента приезжал как «сломались мы», и клиент оставался ждать, пока мы починим то, что чинить ему.
Консюмер не мог отдавать TLS. Он регистрировал слушатель с флагом «нужен HTTPS», но не передавал ни сертификата, ни пароля, ни режима клиентских сертификатов. А DSL слушателя вообще не умел схему soaps. В таком виде HTTPS‑эндпоинт на SOAP было не поднять.
Код фолта с префиксом писался без объявления самого префикса. Код фолта это QName в обеих версиях SOAP, и wst:FailedAuthentication при незанятом wst глазом читается нормально, а строгим клиентом не разрешается. Именно строгие клиенты по коду и ветвятся.
Тестов у коннектора стало пятьдесят шесть.
Развёртывание
Фасад едет отдельным модулем Tsak со своим контекстом identity.soap и без единой ссылки времени компиляции на redb.Identity.Core: разговор с ядром только через direct-vm://.
"identity.soap": { "IdentityTransport": { "Soap": { "Host": "0.0.0.0", "Port": 5021, "Path": "/sts", "Ssl": false, "AllowPlaintext": true, "ClientCertificateMode": "NoCertificate", "Wsdl": true, "EmitHttpCompatHeaders": true } } }
Значения Ssl и AllowPlaintext здесь именно те, что едут в коробке, и они предназначены для разработки. В продакшене ставится Ssl: true с сертификатом, а AllowPlaintext убирается. Мы оставили их в таком виде осознанно: модуль, который на свежей машине отказывается стартовать, читается как поломка, а не как замысел.
Порт по умолчанию отдельный от HTTP‑фасада. Технически SOAP это обычный HTTP/1.1 и мог бы жить на общем хосте. Разделение покупает другое: WS‑Trust можно закрыть от внешнего мира, оставив открытым обычный OIDC, и у двух поверхностей будут свои правила на фаерволе.
EmitHttpCompatHeaders включён по умолчанию, и выключать его не надо, причина та же, что описана выше про ограничение частоты.
Что дальше
Фасад собран, покрыт тестами и демо. На живом воркере он ещё не гонялся, и до этого прогона мы не называем его проверенным в бою.
Дальше по обстоятельствам. Административная поверхность добавляется тем же приёмом, что уже отработан на gRPC. SAML‑assertion появится тогда, когда придёт клиент, которому нужен именно он.
Если у вас есть контур на WCF или CXF, самая дешёвая проверка занимает минуту: поднимите воркер и скормите генератору адрес http://localhost:5021/sts?wsdl. Дальше он сам построит клиента, а тот принесёт настоящий токен из того же ядра, что обслуживает ваш HTTP.
Про второй транспорт есть отдельный разбор: у OpenID‑сервера появился gRPC рядом с HTTP. Про то, как этот же сервер проходил официальный conformance‑suite OpenID Foundation, тоже: как мы прогоняли свой OpenID‑сервер через официальный сьют.
Если было полезно, ⭐ на GitHub поможет другим это найти.
Другие мои статьи: redb.ru/articles, ещё на Хабре.

