У OpenID-сервера появился второй транспорт: gRPC рядом с HTTP, на тех же маршрутах

redb.Identity

Про redb.Identity мы не раз говорили, что он транспортно-агностичен: вся логика живёт в ядре за адресами direct-vm://identity-*, а HTTP это всего лишь фасад поверх них. Звучало убедительно, но проверить это утверждение было нечем. Фасад был ровно один, и «агностичность» оставалась обещанием архитектуры, а не наблюдаемым фактом.

Теперь фасадов два. Рядом с HTTP встал gRPC: те же маршруты ядра, тот же издатель, тот же реестр клиентов, то же хранилище токенов. Один и тот же токен принимается обоими транспортами и получает от них одинаковый вердикт. Про это и статья: что именно появилось, как это включить, и почему оно особенно уместно там, где gRPC уже стал внутренним языком общения.

Кому это нужно

Представьте контур, где сервисы давно разговаривают по gRPC. Есть service mesh, есть дедлайны на вызовах, есть mTLS между подами, есть сгенерированные из .proto клиенты и трассировка, которая понимает gRPC-статусы. И посреди всего этого одна служба, к которой ходят иначе: сервер авторизации по HTTP, с form-encoded телом, своим клиентом, своей обработкой ошибок и своим способом сказать «отказано».

Это не катастрофа, это просто шов. Шов стоит денег: отдельный HTTP-клиент в каждом сервисе, отдельные политики повторов, ошибки, которые не ложатся в общий формат, и телеметрия, где вызов за токеном выглядит инородно рядом с остальными.

gRPC-фасад убирает шов. Токен берётся тем же каналом и тем же способом, что и любой другой внутренний вызов:

var channel = GrpcChannel.ForAddress("https://identity.internal:5001");
var identity = new Identity.IdentityClient(channel);

var token = await identity.TokenAsync(new TokenRequest
{
    GrantType    = "client_credentials",
    ClientId     = "reporting-service",
    ClientSecret = secret,
    Scope        = "identity:users:read",
});

Дедлайн задаётся так же, как везде, mTLS настраивается так же, как везде, а ошибка приходит статусом, который ваш перехватчик уже умеет читать.

Что именно появилось

Протокольная поверхность, то есть то, ради чего сервер авторизации вообще существует для машин:

Операция Адрес Спецификация
Token /identity.v1.Identity/Token RFC 6749 §3.2
Introspect /identity.v1.Identity/Introspect RFC 7662
Revoke /identity.v1.Identity/Revoke RFC 7009
UserInfo /identity.v1.Identity/UserInfo OIDC Core §5.3
Discovery /identity.v1.Identity/Discovery OIDC Discovery 1.0
Jwks /identity.v1.Identity/Jwks RFC 7517
health-проба /grpc.health.v1.Health/Check gRPC Health Checking

Плюс административная поверхность: сорок операций в пяти сервисах (Users, Applications, Groups, Scopes, Tokens), на своём порту.

Один адрес метода это один маршрут. У каждой операции свой RouteId, своя метрика, свой жизненный цикл, и её можно погасить отдельно через control bus. Не один диспетчер с switch внутри, а семь и сорок обычных маршрутов, которые видно в дашборде поштучно.

Контракт лежит в репозитории: redb.Identity.Contracts/Protos/identity.v1.proto. Он опубликован именно как файл, из которого потребитель генерирует свои стабы, и намеренно не компилируется в саму сборку Contracts, чтобы та осталась пакетом без зависимостей.

Форма сообщений

Запросы называют то, что называют RFC, плюс map<string, string> для всего остального. Это не срезание угла: проводная форма этих эндпоинтов всегда была набором пар ключ-значение, так что расширения грантов и вендорские параметры живут именно там.

Ответы типизируют то, что RFC фиксируют, а остальное кладут в google.protobuf.Struct. Claims в userinfo и дополнительные члены интроспекции это открытые множества по замыслу спеки, и типизировать их значило бы соврать. При этом ничего из того, что вернул сервер, не теряется.

Ошибки приходят статусом, а не телом

Вот деталь, которая на HTTP решается сама, а на gRPC требует решения. OAuth-ошибка это документ в теле ответа. Если отдать его успешным вызовом, ни один сгенерированный клиент внутрь не заглянет: он увидит OK и пойдёт дальше с пустой структурой.

Поэтому отказ приходит статусом:

Причина Статус
invalid_client, invalid_token UNAUTHENTICATED
access_denied, unauthorized_client PERMISSION_DENIED
server_error INTERNAL
ограничение частоты (429) RESOURCE_EXHAUSTED
гвард скоупов (403) PERMISSION_DENIED
прочие ошибки OAuth INVALID_ARGUMENT

А машиночитаемый код едет в трейлере, потому что не-OK ответ по протоколу теряет полезную нагрузку:

try
{
    await identity.TokenAsync(request);
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.ResourceExhausted)
{
    var wait = ex.Trailers.GetValue("retry-after");   // секунды
    var code = ex.Trailers.GetValue("error");         // например rate_limited
}

Там же возвращается x-correlation-id, свой или выведенный из текущей трассы, так что логи вызывающего сшиваются с нашими без дополнительной работы.

Главная проверка: один токен, два транспорта, один вердикт

Фасад имеет смысл ровно настолько, насколько он не является вторым сервером авторизации. Проверяется это одним сценарием, и он живёт в демо, а не в обещаниях.

Берётся клиент со скоупом identity:users:write. Он допускается к операции над пользователями и по HTTP, и по gRPC. Берётся клиент, у которого есть только identity:users:read. Он получает отказ на запись на обоих транспортах: PERMISSION_DENIED по gRPC и 403 по HTTP.

Это не совпадение настроек. Таблица скоупов переехала в ядро, за адрес direct-vm://identity-authz-check, и оба фасада спрашивают её. Две копии такой таблицы расходятся ровно в одну сторону: одна начинает разрешать больше другой, причём на той поверхности, где это дороже всего.

Клиент, зарегистрированный по HTTP через динамическую регистрацию, тут же получает токен по gRPC. Тот же издатель, тот же реестр, то же хранилище токенов.

Границы, названные заранее

Браузерные потоки остаются на HTTP: authorize, логин, экран согласия, страницы MFA, подтверждение устройства. Им нужен браузер, редиректы и cookie-сессия, а у gRPC-канала нет ни того, ни другого, ни третьего.

DPoP тоже остаётся на HTTP: RFC 9449 привязывает доказательство к HTTP-методу и URL, поэтому DPoP-proof, предъявленный по gRPC, был бы непроверяемым по построению.

Самообслуживание (/me, регистрация, восстановление пароля, привязка MFA) на gRPC не вынесено сознательно. Это пользовательские сценарии из браузера или мобильного приложения, а не инструмент администратора, и на административном порту они только расширили бы радиус поражения.

Порты не делятся с HTTP-фасадом, и это не предпочтение, а физика: gRPC требует HTTP/2, HTTP-фасад отдаёт HTTP/1.1 и HTTP/2, а у одного слушателя набор протоколов один. Общий хост на такой конфликт падает сразу, а не позже с ошибкой кадрирования.

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

Проще всего через grpcurl, один бинарник, ничего собирать не надо. Reflection мы не отдаём, поэтому указываем опубликованный контракт из репозитория, ровно как это сделал бы потребитель:

grpcurl -plaintext \
  -import-path src/redb.Identity.Contracts/Protos -proto identity.v1.proto \
  -d '{}' localhost:5011 identity.v1.Identity/Discovery

В комплекте идёт демо, которое проходит десять шагов и печатает всё, что уходит и приходит:

pwsh -File demos/demo_grpc_facade.ps1

Health, discovery, регистрация клиента по HTTP, токен по gRPC этим же клиентом, интроспекция, отказ на неверном секрете со статусом и трейлером, административный гейт без токена и с токеном, и наконец сравнение вердиктов на двух транспортах. Весь прогон занимает около десяти секунд.

Есть и просмотрщик на C#, если хочется посмотреть на это из кода: dotnet run в demos/grpc-viewer. Он компилирует те же два .proto так, как их скомпилировал бы любой потребитель.

Сколько это стоит по времени

Цифры с живого воркера, измерены в одном процессе, медиана по серии вызовов:

Операция Медиана
Health/Check 4.2 мс
Discovery (полный путь через ядро) 7.5 мс

Второй столбец это не «время сети», а полный круг: клиент, фасад, direct-vm, ядро, обратно.

Чем это покрыто

Шестьдесят четыре теста на самом фасаде: протокольные операции, негативная матрица (неверный секрет, неизвестный клиент, чужой скоуп, мусорный токен), раздельные порты, административный гейт.

Отдельно стоит interop: клиент на @grpc/grpc-js в контейнере, сгенерированный из опубликованного .proto так же, как его сгенерировал бы любой потребитель. Он ходит в фасад из другого процесса и не разделяет с нами ни строчки кода. Это важнее, чем кажется: наши собственные тесты гоняют наши же маршаллеры против нашего же слушателя, и контракт, расходящийся сам с собой согласованно, прошёл бы их незамеченным.

Проверка гейта устроена так же недоверчиво. Мало иметь зелёные тесты на авторизацию, надо знать, что зелень чем-то держится. Мы убрали обе проверки в отдельной рабочей копии и посмотрели, что покраснеет: покраснели ровно тесты про безопасность, а те, что от гейта не зависят, остались зелёными. Значит гейт несущий, а не декоративный.

Развёртывание

Фасад едет отдельным модулем Tsak со своим контекстом identity.grpc и без единой ссылки времени компиляции на redb.Identity.Core: он говорит с ядром только через direct-vm://. Ставится рядом с модулем ядра, и рядом с HTTP-фасадом, если вы обслуживаете браузеры.

Конфигурация живёт в общем context.json, в секции, устроенной так же, как у HTTP-фасада:

"identity.grpc": {
  "IdentityTransport": {
    "Grpc": {
      "Host": "0.0.0.0",
      "PublicPort": 5011,
      "ManagementPort": null,
      "Ssl": false,
      "ClientCertificateMode": "NoCertificate",
      "Compression": "None",
      "Health": true,
      "EmitHttpCompatHeaders": true
    }
  }
}

Про два параметра стоит сказать отдельно.

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

EmitHttpCompatHeaders включён по умолчанию, и выключать его не надо. Ядро ключует ограничение частоты по IP, блокировку после неудачных входов и метаданные устройства по адресу клиента. Эти проверки при отсутствии адреса не падают, а тихо ничего не делают. То есть выключив флаг, вы не потеряете видимую функцию, вы потеряете защиту при полностью зелёных тестах и дашбордах. Подделать адрес вызывающий не может: транспорт отбрасывает входящие метаданные с зарезервированным префиксом.

Что дальше

Сейчас на gRPC вынесены протокольная поверхность и пять административных групп. Остальная административная поверхность, а её всего 142 операции, открывается по мере надобности: механика фазы построена и проверена, добавление группы это строки в таблице и тесты к ним.

Если у вас внутри уже gRPC, попробуйте самый дешёвый способ: поднимите воркер и выполните тот самый grpcurl на Discovery. Он ответит настоящим документом из ядра, включая HTTP-адреса эндпоинтов. Мы их намеренно не переписываем: это реальные адреса, и подменять их на gRPC-шные значило бы выдать потребителю то, чего не существует.

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

Если было полезно, ⭐ на GitHub поможет другим это найти.

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