redb.Identity
redb.Identity

redb.Identity получил WS‑Trust фасад: Issue, Validate, Cancel, Renew на тех же маршрутах ядра. Тот же JWT, тот же реестр клиентов, WSDL для генератора.

Когда мы делали gRPC‑фасад, тезис был такой: логика redb.Identity живёт в ядре за адресами direct-vm://identity-*, а транспорт это тонкий переводчик поверх них. Второй фасад тезис подтвердил. Но два транспорта, оба современные, оба построенные примерно на одних представлениях о мире, проверяют утверждение мягко. По‑настоящему проверяет третий, устроенный принципиально иначе.

Теперь он есть. Рядом с HTTP и gRPC встал WS‑Trust: IssueValidateCancelRenew по 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, разложенные на маршруты ядра:

Операция

Куда идёт

Что означает

Issue

identity-token

выдать токен

Renew

identity-token

обновить по refresh‑токену

Validate

identity-introspect

проверить, живой ли

Cancel

identity-revoke

отозвать

Учётные данные клиента приезжают в 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 остаётся только отображение вердикта в свой словарь:

Вердикт ядра

Фолт

UnauthenticatedForbidden

wst:FailedAuthentication

InvalidScope

wst:InvalidScope

RateLimitedUnavailableTimeoutServerError

soap:Server

остальное

wst:InvalidRequest

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-svcutilsvcutil или wsimport делают своё дело.

STS, отвечающий только на POST, для этой аудитории просто непригоден.

Как посмотреть за десять секунд

Ставить нечего: SOAP это обычный HTTP с XML‑телом, поэтому демо написано на голом Invoke-WebRequest и печатает всё, что уходит и приходит.

pwsh -File demos/demo_soap_facade.ps1

Десять шагов: WSDL, регистрация клиента по HTTP, Issue по SOAP, ValidateCancel, повторный 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, ещё на Хабре.