redb.Identity: OAuth 2.1 / OpenID сервер на .NET, где протокол отделён от транспорта, по шине или вообще без сети

redb.Identity: OAuth 2.1 / OpenID сервер на .NET, где протокол отделён от транспорта, по шине или вообще без сети

Большая и сложная статья — уровень «сложный», ~30 минут чтения. Это самый полный разбор redb.Identity: изнутри, с кодом, без сокращений. Полный, построчно выверенный каталог стандартов, production-grade внутренности (JWKS live-rotation без рестарта, cluster-safe примитивы, password-hashing pipeline), audit «куда угодно» с интеграционными тестами, logical-level backup, host-agnostic-деплой и архитектура addon-ов рядом с identity. Если нужен обзорный вход без глубины — берите короткую версию; здесь — полный проход. Устраивайтесь удобнее.

Большая и сложная статья — уровень «сложный», ~30 минут чтения. Это самый полный разбор redb.Identity: изнутри, с кодом, без сокращений. Полный, построчно выверенный каталог стандартов, production-grade внутренности (JWKS live-rotation без рестарта, cluster-safe примитивы, password-hashing pipeline), audit «куда угодно» с интеграционными тестами, logical-level backup, host-agnostic-деплой и архитектура addon-ов рядом с identity. Если нужен обзорный вход без глубины — берите короткую версию; здесь — полный проход. Устраивайтесь удобнее.

Есть три привычных способа сделать OAuth/OIDC в .NET, и каждый чем-то неудобен.

Первый — ASP.NET -привязанные решения (Duende IdentityServer, ASP.NET Identity, сэмплы OpenIddict). Мощно, но каждый эндпоинт — это HTTP-middleware. Захотелось дёрнуть token из воркера или из консьюмера шины? Поднимай HTTP-листенер и ходи через loopback. Хочешь протестировать конвейер выдачи токена в изоляции? Готовь WebApplicationFactory .

Второй — готовые IAM-платформы (Keycloak, Auth0, Okta). Богато по фичам, но это отдельный сервис со своим рантаймом, своей админкой, своей базой, своей моделью конфигурации и своим деплоем. Мультиарендно — да, встраиваемо — нет.

Третий — написать своё . И в третий раз за десятилетие переизобрести Code+PKCE, ротацию refresh-токенов, хранение согласий, отзыв сессий, защиту от replay в MFA, ротацию JWKS, шаринг ключей между репликами и backchannel-logout по RFC 8417.

redb.Identity — это недостающий четвёртый вариант, и его суть в одном слове: transport-agnostic. Протокол отделён от транспорта.

где то тут санкции Это в первую очередь полноценный OAuth 2.1 / OIDC сервер — поднимаете его классически по HTTP, выставляете наружу для внешних клиентов, discovery, JWKS, всё как положено. Просто внутри каждый эндпоинт — это маршрут redb.Route, а не HTTP-middleware. А direct-vm:// — это внутренний транспорт redb.Route между контекстами (в том же ряду, что http:// , rabbitmq:// , kafka:// , только сетевой хоп заменён на вызов между RouteContext 'ами в процессе).

Из-за этого один и тот же token можно дёрнуть по HTTP, по gRPC, по очереди — или, если зовущий модуль живёт в том же воркере, вообще без сети, тем же внутренним транспортом. Последнее — «никакого сетевого хопа, просто вызов из соседнего контекста» — и даёт режим встраивания. Но это следствие transport-agnostic-модели, а не единственный сценарий: чаще всего Identity стоит нормальным сервером за HTTP, а in-process-вызов — приятный бонус там, где он нужен. Стандарты и модель хранения — как у полноценного IS-сервера; меняется только то, каким транспортом вы к нему обращаетесь.

Сегодня это вышло в open source под Apache 2.0: [github.com/redbase-app/redb-identity]( https://github.com/redbase-app/redb-identity ), пакеты — на nuget.org ( redb.Identity.* , версия 1.2.0). Это самая большая статья в серии — по делу, с кодом и без маркетинга: transport-agnostic-ядро, RTTI-схемы в БД, полный, построчно выверенный каталог стандартов, production-grade фичи (JWKS live-rotation, cluster-safe примитивы, password-hashing pipeline), audit «куда угодно» доказанный интеграционными тестами, logical-level backup, host-agnostic-деплой и архитектурная свобода addon-ов рядом с identity.

Цикл про redb и redb.Route. Это продолжение серии, свежие статьи — сверху: redb 3.3.0: решение уровня энтерпрайз на .NET — своя БД, свой Apache Camel и рантайм с дашбордом (и всё это бесплатно) redb.Route: два маршрута за вечер — от отладочного воркера до энтерпрайза на Tsak redb.Route — уходим от MassTransit, идём к Apache Camel: Kafka, Scatter‑Gather и транзакции Анатомия SQLite-провайдера: типизированное хранилище для десктопа, мобайла и Blazor WASM Apache Camel под .NET: HTTP-коннектор без ASP.NET MVC + Content-Based Router Полный список — в профиле . Исходники: github.com/redbase-app . Про саму БД: redb.ru .

Цикл про redb и redb.Route. Это продолжение серии, свежие статьи — сверху:

Apache Camel под .NET: HTTP-коннектор без ASP.NET MVC + Content-Based Router

Apache Camel под .NET: HTTP-коннектор без ASP.NET MVC + Content-Based Router

Полный список — в профиле . Исходники: github.com/redbase-app . Про саму БД: redb.ru .

Забегая вперёд: чем я это подкреплю

Такую статью легко написать и трудно проверить. «Полноценный OAuth 2.1 / OIDC сервер» — фраза, которую может набрать кто угодно. Поэтому до архитектуры, до единой строчки кода — вот то, что иначе вам пришлось бы принимать на веру.

Это официальный conformance-сьют OpenID Foundation — тот самый, которым OIDF сертифицирует провайдеров. Прогнан против живого redb.Identity по нативному HTTPS. Не наши тесты. Их.

Basic OP: 35 модулей, ноль провалов. Четыре REVIEW — пройдены; сьют просто требует, чтобы человек руками загрузил скриншот как доказательство для сертификации. Один SKIPPED — request objects, которые мы честно не объявляем в discovery. Один WARNING — два дополнительных claim'а в id_token, которые мы положили туда осознанно , и ниже я подробно объясняю почему, а не прячу.

И это не была формальность. Сьют нашёл в нашем сервере настоящие дефекты — в том числе утечку PII в id_token — и все они закрыты. Разбор каждого, без прикрас, — ниже, в разделе про conformance.

Чего я не заявляю: значка OpenID Certified™ у нас нет. Это товарный знак, он выдаётся OIDF по результатам формальной (и платной) подачи. Правда — ровно то, что на экране: сервер гоняется против официального сьюта, результаты лежат в репозитории.

Теперь — архитектура.

Три слоя, чтобы дальше было понятно, кто есть кто

redb.Identity стоит не на пустом месте, а на нашей экосистеме. Совсем кратко:

redb — типизированное хранилище для .NET. Пишешь обычный POCO-класс, вешаешь атрибут [RedbScheme] — и работаешь с ним через полный LINQ, серверными запросами, без миграций и без Include . Провайдеры: PostgreSQL, MSSQL, SQLite.

redb — типизированное хранилище для .NET. Пишешь обычный POCO-класс, вешаешь атрибут [RedbScheme] — и работаешь с ним через полный LINQ, серверными запросами, без миграций и без Include . Провайдеры: PostgreSQL, MSSQL, SQLite.

redb.Route — интеграционный движок в духе Apache Camel: маршруты From(...)…​.To(...) , 30+ коннекторов, паттерны интеграции (EIP), транзакции, телеметрия.

redb.Route — интеграционный движок в духе Apache Camel: маршруты From(...)…​.To(...) , 30+ коннекторов, паттерны интеграции (EIP), транзакции, телеметрия.

redb.Tsak — рантайм, который берёт маршруты и превращает их в прод-сервис: дашборд, hot-reload модулей ( .tpkg ), кластер с координатором.

redb.Tsak — рантайм, который берёт маршруты и превращает их в прод-сервис: дашборд, hot-reload модулей ( .tpkg ), кластер с координатором.

redb.Identity — четвёртый слой поверх этих трёх. Он не владеет собственной схемой БД и не поднимает собственный HTTP-стек как обязательный . Он берёт движок маршрутов redb.Route, кладёт на него конвейер OpenIddict, а хранение отдаёт в redb. Что из этого получается — дальше.

Весь код в примерах — на английском, весь текст — по-русски. Всё лежит в репозитории, можно повторять.

Killer-фича №1: протокол ≠ транспорт

Каждый публичный эндпоинт Identity зарегистрирован на маршруте direct-vm://identity-* . direct-vm — это внутрипроцессный, кросс-контекстный, синхронный zero-copy транспорт redb.Route. Из этого бесплатно вытекают три следствия.

Представьте: у вас в одном Tsak-воркере крутится Identity и ваш бизнес-модуль. Бизнес-модулю нужен service-account токен, чтобы сходить в соседний API. В ASP.NET -мире это HTTP-запрос на самого себя через loopback. Здесь — обычный вызов внутри процесса:

Что здесь происходит. По маршруту едет заказ из очереди — он и есть тело сообщения. Посередине мы делаем побочный вызов : спрашиваем у Identity service-account токен, забираем из ответа access_token и вешаем его заголовком на текущее сообщение. Заказ при этом не меняется. В конце заказ (уже с Authorization ) уходит в чужой API.

Почему токен берут в .Process(...) , а не через .To(IdentityEndpoints.Token) в DSL — вопрос закономерный, и ответ важный. Потому что .To() — это конвейер : ответ становится новым телом сообщения. Напишешь так:

— и заказ потерян : в чужой API улетит JSON с access_token'ом вместо позиций корзины.

А нужно другое: сходить в сторону, взять из ответа одно поле, приклеить к текущему сообщению и не тронуть его тело . Это классический EIP — Content Enricher , и RequestBody(...) внутри процессора — канонический способ его выразить (в Apache Camel это ровно ProducerTemplate.requestBody ). В redb.Route есть и декларативный .Enrich(uri, merge) , но он отправляет в ресурс текущий exchange — то есть заказ уехал бы в token-эндпоинт, а Identity ждёт grant_type=client_credentials . Enrich — про «обогати это сообщение», а у нас побочный вызов с другим payload'ом .

И да, в последней строке http:// — так и должно быть. pricing-api — чужой сервис, до него по определению идём по сети, никакая архитектура этого не отменит. Соль в том, что сетевых вызовов стало один вместо двух :

ASP.NET -привязанный IS

здесь

получить токен

HTTP на самого себя (loopback)

direct-vm:// — вызов метода, сети нет

дёрнуть pricing-api

HTTP (неизбежно)

Мы убрали первый — тот, которого вообще не должно было существовать. Ходить по HTTP к себе же в процесс, сериализуя JSON и жмя TLS-руку с самим собой, — это налог, который платят просто потому, что token оказался HTTP-middleware'ом.

Никакого HTTP-листенера. Никакого TLS-хендшейка. Никакого JSON по loopback. Exchange течёт прямо из вашего маршрута в процессор Identity и обратно — в том же потоке, с тем же экземпляром IExchange . Полная стоимость: вызов метода плюс та работа, которую и так делает конвейер OpenIddict.

Это ровно то, что не умеют ASP.NET -привязанные IS-серверы: у них token — это HTTP-middleware, и вызвать его «изнутри» без сети нельзя в принципе.

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

Добавить транспорт — это положить ещё один .tpkg , который указывает на direct-vm:// . Ядро при этом не трогается. Убрать транспорт — это rm facade.tpkg . Сломать в фасаде нечего — там нет логики.

Хотите заменить HTTP на RabbitMQ целиком? В ASP.NET -стеке это капитальный рефакторинг. Здесь — выкинь HTTP-фасад, оставь Core .

Ниже — как это выглядит на уровне процессоров. HTTP-фасад ( redb.Identity.Http ) объявляет HTTP-маршрут и форвардит его в direct-vm:

То есть HTTP — это тонкий адаптер : распарсил запрос, форварднул в direct-vm://identity-token , забрал ответ, сериализовал. Вся бизнес-логика живёт в direct-vm-маршрутах.

Единственное взаимодействие OAuth, которое фундаментально требует браузера, — это редирект authorization-code (и его брат, verification URL для device code). Всё остальное — token , refresh_token , introspect , revoke , userinfo , запрос device_code, management API, MFA verify, SCIM — транспортно-нейтрально и отлично работает через gRPC, AMQP, MQ или прямой вызов.

Flow / эндпоинт

Нужен HTTP + браузер

Работает на любом транспорте

client_credentials (M2M)

authorization_code — редирект на /authorize

authorization_code — обмен code → token

refresh_token , revoke , introspect , userinfo

device_code — начальный запрос

device_code — verification URL для пользователя

Management API, SCIM 2.0, запрос аудита

Один и тот же TokenEndpointProcessor отрабатывает, пришёл ли вызов по HTTP, по gRPC, по RabbitMQ или прямым direct-vm -вызовом из соседнего модуля. Один конвейер, один набор тестов, один аудит-трейл.

Embedding. Identity можно поднять как in-process библиотеку без Kestrel и без сетевого слоя — прямой in-process call в тот же набор процессоров.

Multi-transport. Один и тот же EventDispatchProcessor публикует identity-события через redb.Route -коннекторы — Kafka, RabbitMQ, Elasticsearch, файл, любая комбинация. Без переписывания.

Testability. Интеграционные тесты гоняют бо́льшую часть сценариев через direct-vm , не поднимая Kestrel. Если бы каждый тест ходил по HTTP — прогон был бы кратно дольше.

Multi-port изоляция. Public OIDC endpoints ( /connect/token , /connect/authorize , …) могут жить на одном HTTP-порту, management API ( /api/v1/identity/* , SCIM) — на другом, файрволлятся независимо. В HTTP-фасаде это просто конфиг — пайплайн тот же.

Killer-фича №2: никаких миграций

Это тянется из redb, но для identity-сервера особенно приятно. redb.Identity не владеет схемой БД . Он стоит на redb, где «таблица» — это обычный C#-класс с атрибутом. Вот как выглядит пользователь:

Нужно добавить пользователю поле — LoyaltyTier , ManagerSubject , DepartmentCode ? Вы буквально дописываете его в класс. Никакой миграции, никакого звонка DBA, никакого простоя. redb читает/пишет его со следующего мгновения; как только оно оказалось в проде — запросы уже могут по нему фильтровать и его проецировать.

Отдельно про CustomClaims . Это не nvarchar(max) -блоб и не jsonb , к которому вы потом руками пишете GIN-индексы. Это Dictionary<string,string> на props , и каждый ключ — отдельная запросимая, индексируемая строка. ContainsKey , индексатор, вложенный доступ — всё это работает нативным LINQ на сервере. Для сравнения: типичный EF-Core identity-сервер держит кастомные claims либо блобом, либо разносит по восьми сателлитным таблицам с join'ами.

И горячее/холодное разделение: логин, хеш пароля, статус живут в реляционной таблице users (горячие ключи, узкие индексы), а холодный OIDC-профиль лежит в props-строках, связанных через RedbObject.key = users._id . Никаких широких переиндексированных строк, никаких JSON-lookup'ов на горячем пути.

Весь Identity собирается из 24 типизированных redb-схем ( identity.application , identity.scope , identity.token , identity.session , identity.user , identity.group , identity.mfa , identity.webauthn_* , identity.federation_provider , identity.claim_mapper , identity.dpop_consumed_jti , identity.signing_key , …). Все — обычные C#-классы. Schema sync делает InitializeAsync() , никаких файлов миграций.

Подавляющее большинство identity-серверов разводит хранилище на пол-дюжины компонентов: EF Core + миграции для users/apps/claims, Redis для session cache и MFA OTP, файловая система / Azure Key Vault для DataProtection key ring, отдельная таблица под signing keys, ElasticSearch под audit log, что-нибудь ещё под rate-limit counters. Каждый компонент — свой клиент, своя инициализация, свои health checks, свой backup-план, своя миграционная стратегия.

У нас один сторадж — REDB — обслуживает всё . Это типизированные схемы данных, объявленные в самой БД — runtime type information уровня базы:

Понятие из языка

class Foo { int X; string Y; } (декларация типа)

строка в _schemes с описанием полей

new Foo { X = 42, Y = "bar" } (инстанс)

строка в objects + строки в values с типизированными слотами

typeof(Foo) / obj.GetType() (RTTI)

objects. id_scheme → schemes. id

Generic constraint where T : class, new()

C#-side proxy RedbObject<TProps>

Это и есть RTTI на уровне БД : типизированная информация о структуре данных живёт в БД , а не в C#-классах. C#-классы ( UserProps , ApplicationProps , …) — это просто type-safe view на схемы. Что это даёт identity-серверу:

Один query language на всё. Поиск пользователя, токена по reference_id, authorization по subject GUID — один IRedbService , один LINQ-провайдер, один транзакционный контекст:

Все OpenIddict-сторы ( RedbApplicationStore , RedbAuthorizationStore , RedbTokenStore , RedbScopeStore ) плюс наши ( PropsSigningKeyStore , PropsServerSideOtpStore , PropsWebAuthnChallengeStore , PropsPasswordHistoryStore ) живут поверх одного интерфейса.

One backup story. Бекапите Postgres — забэкаплен ВЕСЬ identity-стек, включая ключи (DataProtection-зашифрованные в той же БД), сессии, ключи подписи, аудит. Нет ни одного «а вот этот компонент ещё в Redis лежит, его бекапить отдельно».

Multi-tenant изоляция через схемы. Хотите чтобы tenant A видел свои UserProps , а tenant B — слегка другой формы UserProps ? Делаете схемы per-tenant — разделение физическое через id scheme .

Pro-режим с PVT. В Pro-сборке есть Precomputed Value Tables — материализованные индексы для hot-path запросов. Запрос по value_string == clientId идёт через PVT-индекс без развёрнутого join'а по _values . Подробности — в статье про REDB-индексы .

Killer-фича №3: тот же сервер — ещё и как встроенная библиотека

Это прямое следствие первых двух: раз протокол отделён от транспорта, а direct-vm:// — внутренний транспорт между контекстами, то один из доступных режимов — вообще без сетевого фасада . Не замена «нормальному серверу за HTTP», а дополнительная опция там, где Identity живёт в одном воркере с тем, кто его зовёт.

Обычно «поставить identity-сервер» означает поднять отдельный процесс: свой рантайм, свой порт, своя админка, своя БД, свой деплой. Даже «встраиваемые» ASP.NET -библиотеки всё равно требуют HTTP-пайплайна, чтобы их вызвать.

В redb.Identity вы можете взять только движок — redb.Identity.Core — и ни одного фасада. Никакого Kestrel, никакого HTTP. Просто ссылаетесь на пакет, поднимаете конвейер OpenIddict внутри своего процесса и дёргаете эндпоинты через direct-vm:// из своего кода:

Это меняет модель встраивания. identity-сервер перестаёт быть «сервисом рядом» и становится библиотекой внутри вашего процесса , у которой есть полноценный OAuth 2.1 / OIDC под капотом — но общаетесь вы с ним вызовом метода, а не запросом по сети. Когда наступит момент выставить те же эндпоинты наружу для внешних клиентов — добавляете HTTP-фасад одним .tpkg , и те же самые процессоры начинают отвечать ещё и по HTTP. Ядро не меняется.

Ни Duende, ни Keycloak, ни Auth0 так не умеют: у первого эндпоинт — это всегда HTTP-middleware, у остальных это вообще внешний сервис с сетевым хопом.

Два контекста, один воркер

Архитектурно это выглядит так — два RouteContext внутри одного Tsak-воркера:

Отдельно стоит подчеркнуть изоляцию по project-reference . HTTP-фасад компилируется, не видя ни одного типа из redb.Identity.Core :

Фасад знает только про wire-DTO и константы эндпоинтов ( redb.Identity.Contracts ) и пару SPI-интерфейсов. Внутренние типы ядра не протекают через границу ABI. CI роняет сборку, если using redb.Identity.Core; вдруг появится где-то под redb.Identity.Http/ . Именно это делает «.tpkg-историю» честной: два пакета собираются и версионируются независимо.

Раз уж каждый эндпоинт — это маршрут, полезно посмотреть, из чего он собран. Токен-эндпоинт — не «метод контроллера», а декларативный пайплайн redb.Route:

Три вещи, которые тут видно и которые в ASP.NET -стеке обычно размазаны по middleware, фильтрам и атрибутам: транзакционная граница ( WithRedbTx — либо токен выдан и записан целиком, либо откат), rate-limit до того, как запрос дойдёт до дорогой работы, и аудит как ответвление ( WireTap ) — оно не блокирует выдачу и не может её уронить. Тот же самый TokenEndpointProcessor отработает, пришёл вызов по HTTP, по gRPC или прямым direct-vm . Один пайплайн, один аудит-трейл, один набор тестов.

Хранилище: что это даёт против EF-Core-на-таблицах

redb.Identity

Типичный EF-Core identity-сервер

Движки хранения

PostgreSQL, MSSQL и SQLite из одного кода — меняется пакетом провайдера

один провайдер на сборку; смена = переписать EF-модель + миграции

Эволюция схемы

нет миграций. Дописал поле в *Props -класс — схема подхватит на следующем InitializeAsync

сгенерируй миграцию, отревьюй SQL, прогони Update-Database , надейся на rollback

Кастомные claims / расширения арендатора

Dictionary<string,string>? CustomClaims — каждый ключ становится запросимой индексируемой строкой

jsonb / nvarchar(max) -блоб; GIN / computed-column индексы пишешь сам

Мульти-провайдерные federation-связки

Dictionary<string, ExternalIdentity> — нативные строки props, горячий обратный lookup

join-таблица one-to-many, скаффолдинг на каждого провайдера

Горячее/холодное разделение

горячие ключи в реляционном users ; холодный профиль — в props, связан по key = users._id

либо всё в одной широкой таблице, либо 8 сателлитных

Мультиарендная изоляция данных

GetRedbService("identity") — именованный инстанс может целиться в отдельную БД / схему / подключение

один DbContext ; изоляция = отдельные ASP.NET -приложения

PostgreSQL 13+

redb.Postgres

Microsoft SQL Server 2019+

redb.MSSql

SQLite 3.44+

redb.SQLite

Identity ссылается только на OSS-абстракцию redb.Core — провайдер выбирает хост-воркер , сам код Identity его не называет. И это не «на словах»: один и тот же тестовый набор (1768 тестов) зелёный на всех трёх провайдерах, Passed: 1767, Skipped: 1, Failed: 0 — на PostgreSQL, MSSQL и SQLite, переключается одной env-переменной REDB_PROVIDER . (Единственный skip — известный тест на teardown хоста под PG, не дыра в продукте.)

Что со стандартами — полный каталог

Тут коротко нельзя, потому что именно ради этого identity-сервер и берут. Под 40 стандартов — и это не «упомянуты в коде», а реализованы: рядом с каждым ассертом ссылка на секцию спеки, а рядом с контрактом своя demo_*.ps1 -проба по живому серверу.

Каталог мы перед публикацией проверили построчно — по коду, по живому discovery и по демкам. Несколько строк не выдержали проверки: часть переформулирована точнее, часть вычеркнута и переехала в раздел «чего нет» ниже, а три вещи оказалось честнее не вычёркивать, а дописать — так появились claims -параметр, SCIM Enterprise и loopback-редирект. Список ограничений в конце — не отписка, а результат этой же проверки: любую строчку отсюда читатель опровергнет нашим же /.well-known/openid-configuration за десять секунд, если мы соврём.

RFC / спека

RFC 6749 OAuth 2.0 Framework

grant types, scopes, error responses

demo_client_ credentials.ps 1 , demo_authcode_ pkce.ps 1 , demo_refresh_ rotation.ps 1 , demo_password_ ropc.ps 1

RFC 6750 Bearer Token Usage

Authorization: Bearer , WWW-Authenticate: Bearer challenge

demo_ userinfo.ps 1 — отдельно ассертит WWW-Authenticate на missing/empty/garbage/tampered

RFC 6585 Additional HTTP Status Codes

429 Too Many Requests + Retry-After

demo_throttle_rfc6585.ps1 — parallel burst + recovery + per-key isolation

RFC 7009 Token Revocation

/connect/revoke

demo_introspect_ revoke.ps 1

RFC 7521 Assertion Framework

ассершены для аутентификации клиента ( client_assertion ). Grant-типов на ассершенах ( jwt-bearer , saml2-bearer ) нет

покрыто через RFC 7523

RFC 7523 JWT Bearer Assertion (private_key_jwt)

client assertion на token / introspect / revoke / par / device

demo_private_key_ jwt.ps 1 — DCR с inline JWKS + token + introspect + tamper-negative

RFC 7591 Dynamic Client Registration

POST /connect/register

demo_dcr_ lifecycle.ps 1

RFC 7592 DCR Management Protocol

GET/PUT/DELETE /connect/register/{client_id}

demo_dcr_ lifecycle.ps 1 (тот же файл)

RFC 7636 PKCE

code_verifier / code_challenge / S256 ( plain отклоняется по OAuth 2.1)

demo_authcode_ pkce.ps 1

RFC 7662 Token Introspection

/connect/introspect

demo_introspect_ revoke.ps 1

RFC 8252 OAuth 2.0 for Native Apps

public-клиенты с обязательным PKCE + loopback-редирект §7.3: порт не сравнивается (иначе az login -подобные CLI просто не заведутся — они берут у ОС случайный порт при старте)

demo_loopback_ redirect.ps 1 — 14 ассертов, почти все негативные

RFC 8414 Authorization Server Metadata

/.well-known/oauth-authorization-server

demo_discovery_ jwks.ps 1

RFC 8628 Device Authorization Grant

/connect/deviceauthorization , polling 5s

demo_device_ code.ps 1 + non-interactive demo_device_code_ ci.ps 1 для CI

RFC 8693 Token Exchange

impersonation + delegation chain ( act ). Фича opt-in; subject_token_type — только access_token

demo_token_ exchange.ps 1 — positive + negative

RFC 8725 JWT BCP

allow-list алгоритмов для DPoP-proof'ов и client-assertion'ов, none отвергается

demo_ dpop.ps 1 (DPoP-proof), discovery ( *_auth_signing_alg_values_supported )

RFC 9068 JWT Profile for Access Tokens

typed at+jwt . По умолчанию access-токен ещё и JWE-шифруется — ресурс-серверу нужен либо introspection, либо DisableAccessTokenEncryption для локальной валидации

заголовок токена видно в demo_ jwt.ps 1

RFC 9126 Pushed Authorization Requests (PAR)

/connect/par , require_pushed_authorization_requests per-client

demo_ par.ps 1 + demo_par_per_ client.ps 1

RFC 9207 OAuth 2.0 Authorization Response iss

iss в auth-response

demo_auth_ extras.ps 1

RFC 9449 DPoP

proof JWT validation, replay cache, DPoP-Nonce , resource-server валидатор

demo_ dpop.ps 1

OpenID Connect Core 1.0

code flow (по OAuth 2.1 implicit и hybrid не поддерживаются). id_token (RS256), sub/aud/exp/iat, nonce, prompt, max_age, acr_values, claim mappers

demo_discovery_ jwks.ps 1 , demo_claim_ probes.ps 1 , demo_prompt_max_ age.ps 1 , demo_acr_ values.ps 1

OIDC Core §5.1 полный набор profile

все 14 claims, updated_at — JSON- число , *_verified — JSON- boolean

demo_claim_ probes.ps 1

OIDC Core §5.4 доставка claims

scope-claims (profile/email/phone/address) отдаются из UserInfo , а не зашиваются в id_token — id_token остаётся токеном о факте аутентификации, а не контейнером с PII

demo_claim_ probes.ps 1 — ассертит отсутствие PII в id_token

OIDC Core §5.5 claims -параметр

RP называет точные claims вместо того, чтобы тянуть весь profile -scope ради одного name , и выбирает канал: userinfo или id_token . Поддержаны essential / value / values

demo_claims_ parameter.ps 1 — 14 ассертов, с негативами

OpenID Connect Discovery 1.0

/.well-known/openid-configuration + JWKS

demo_discovery_ jwks.ps 1 + ручная RS256-верификация

OpenID Connect Dynamic Client Registration 1.0

расширения RFC 7591 для OIDC client_metadata

demo_dcr_ lifecycle.ps 1

OpenID Connect RP-Initiated Logout 1.0

/connect/logout + post_logout_redirect_uri

demo_logout_ endsession.ps 1

OpenID Connect Back-Channel Logout 1.0

backchannel_logout_uri + JWT logout token + pull-based revoked-SID лента для мульти-реплики RP

demo_backchannel_ logout.ps 1

Каталог — это заявка. Доказательство — прогон того самого conformance-сьюта OpenID Foundation, которым OIDF и сертифицирует провайдеров . Не наши тесты, не «у нас всё работает» — официальный сьют против живого сервера. Большинство .NET-реализаций OAuth/OIDC его в глаза не видели; redb.Identity мы гоняем через него как настоящий OP.

Basic OP — 35 модулей, 0 провалов:

Что это значит

прошли

сьют требует скриншот от человека (login-форма, страница ошибки) — засчитано

два дополнительных claim в id_token — наше осознанное расширение, разбор ниже

request objects (RFC 9101) — мы их честно не объявляем в discovery, сьют корректно пропускает

Профиль Config OP — тоже без провалов (нативный HTTPS, без reverse-proxy).

Сьют — штука безжалостная: он проверяет ровно те требования RFC, на которых самодельные серверы обычно и рассыпаются. И это не «прогнали для галочки» — он нашёл настоящие дефекты , и вот они, без прикрас:

ошибки авторизации уходят только на зарегистрированный клиентом redirect_uri (RFC 6749 §4.1.2.1) — закрытый error-open-redirect, а не «редиректим по присланному URL»;

ошибки авторизации уходят только на зарегистрированный клиентом redirect_uri (RFC 6749 §4.1.2.1) — закрытый error-open-redirect, а не «редиректим по присланному URL»;

повторно предъявленный authorization code → 400 invalid_grant , а не 401 invalid_token (§5.2);

повторно предъявленный authorization code → 400 invalid_grant , а не 401 invalid_token (§5.2);

Cache-Control: no-store на token / introspect / revoke (§5.1);

Cache-Control: no-store на token / introspect / revoke (§5.1);

email_verified / phone_number_verified — JSON- boolean , а не строки (OIDC §5.1);

email_verified / phone_number_verified — JSON- boolean , а не строки (OIDC §5.1);

prompt=login / max_age уводят на /login и завершаются после ре-логина; маркер ре-аутентификации привязан к session id — ни петли, ни обхода в пределах секунды;

prompt=login / max_age уводят на /login и завершаются после ре-логина; маркер ре-аутентификации привязан к session id — ни петли, ни обхода в пределах секунды;

PII уезжала в id_token. Claims из scope'ов profile / email / phone / address зашивались прямо в id_token, хотя в code-flow их место — UserInfo (OIDC §5.4). id_token пересылают третьим сторонам и пишут в логи как доказательство факта входа, так что телефон пользователя уезжал дальше, чем RP вообще предполагал. Теперь их там нет;

PII уезжала в id_token. Claims из scope'ов profile / email / phone / address зашивались прямо в id_token, хотя в code-flow их место — UserInfo (OIDC §5.4). id_token пересылают третьим сторонам и пишут в логи как доказательство факта входа, так что телефон пользователя уезжал дальше, чем RP вообще предполагал. Теперь их там нет;

UserInfo, наоборот, отдавал лишнее — внутреннюю кухню токена: oi_* -поля OpenIddict, jti / exp / iat / at_hash . Это свойства токена , а не пользователя; UserInfo (§5.3) обязан возвращать только claims пользователя;

UserInfo, наоборот, отдавал лишнее — внутреннюю кухню токена: oi_* -поля OpenIddict, jti / exp / iat / at_hash . Это свойства токена , а не пользователя; UserInfo (§5.3) обязан возвращать только claims пользователя;

/connect/userinfo не принимал access-токен в теле POST-запроса (RFC 6750 §2.2);

/connect/userinfo не принимал access-токен в теле POST-запроса (RFC 6750 §2.2);

набор profile был неполным — сьют сверяет UserInfo ровно со списком из §5.1 и ругается на каждый недостающий claim.

набор profile был неполным — сьют сверяет UserInfo ровно со списком из §5.1 и ругается на каждый недостающий claim.

Заодно сьют выдал WARNING на oidcc-claims-essential — « name не найден в userinfo». Копнули: тест просит name через claims -параметр (§5.5), а он у нас не был реализован вовсе, и наш собственный discovery это честно признавал ( "claims_parameter_supported": false ). Реализовали.

Два WARNING, которые мы оставили сознательно

Самый первый модуль, oidcc-server , заканчивается с двумя предупреждениями:

Прятать не будем — расскажем, почему они там и почему остаются.

Сначала важное: это WARNING , а не FAILURE . OIDC Core не запрещает дополнительные claims в id_token. Сьют предупреждает потому, что лишний claim может означать утечку данных о пользователе — и его собственный текст это признаёт: «…или что реализовано расширение, о котором сьют не знает» . Наш случай — ровно второй.

Ни один из этих двух claims не является данными пользователя :

oi_tkn_id — идентификатор записи токена в сторе. Именно он позволяет отозвать id_token и обеспечивает работу back-channel logout. Выкинуть его — значит потерять отзыв id_token'ов. Это обмен реальной возможности на чистый список предупреждений, и мы такой обмен делать не стали.

oi_tkn_id — идентификатор записи токена в сторе. Именно он позволяет отозвать id_token и обеспечивает работу back-channel logout. Выкинуть его — значит потерять отзыв id_token'ов. Это обмен реальной возможности на чистый список предупреждений, и мы такой обмен делать не стали.

redb:user_id — наш приватный claim с namespace по конвенции RFC 7519 §4.3. Публичный sub — это GUID (стабильный и корректный между инстансами), а в реляционной таблице users горячий ключ — bigint. Этот claim позволяет клиенту раскодировать id token и сджойнить пользователя со своей таблицей по нашему внутреннему id, не ходя лишний раз на сервер.

redb:user_id — наш приватный claim с namespace по конвенции RFC 7519 §4.3. Публичный sub — это GUID (стабильный и корректный между инстансами), а в реляционной таблице users горячий ключ — bigint. Этот claim позволяет клиенту раскодировать id token и сджойнить пользователя со своей таблицей по нашему внутреннему id, не ходя лишний раз на сервер.

И вот тут — то, что доказывает, что это не отмазка. В том же самом прогоне мы третий такой claim из id_token удалили. OpenIddict клал туда ещё и oi_au_id — свою внутреннюю ссылку на запись авторизации. Ей нечего делать в токене, который отдают клиенту: она не значит для него ничего и защитить её было нечем. Убрали (на access_token оставили — там она нужна для introspection).

То есть мы не махнули рукой на предупреждения. Мы разобрали каждое и оставили ровно те два, за которые можем ответить.

Вот это и есть граница между «набросал OAuth за выходные» и сервером, который проходит те же проверки, что промышленные IdP. Полная разбивка по модулям и локальный сетап — в OPENID_ CERTIFICATION.md .

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

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

RFC 7515 JWS

подпись токенов

RFC 7517 JWKS

публикация ключей

RFC 7518 JWA

набор алгоритмов

RFC 7519 JWT

сам формат токена

RFC 7638 JWK Thumbprint

как jkt для DPoP

RFC 7800 cnf / PoP

proof-of-possession

RFC 8176 amr

authentication methods reference

RFC 7643 SCIM Schema

User, Group, Meta

demo_ scim.ps 1

RFC 7643 §4.3 Enterprise User extension

department , manager , employeeNumber , costCenter , organization , division . manager — комплексный атрибут; $ref и displayName выводятся , а не хранятся (иначе протухнут при переименовании руководителя)

demo_scim_ enterprise.ps 1 — 24 ассерта

RFC 7644 SCIM Protocol

CRUD + ETag concurrency (RFC 7232 → 412) + discovery. Filter — односоставный : userName / externalId / displayName / emails.value , без and / or . Bulk — opt-in ( Features.EnableScimBulk , по умолчанию выключен, и ServiceProviderConfig это честно показывает)

demo_ scim.ps 1 (CRUD) + demo_scim_ bulk.ps 1 (bulk) + demo_scim_ etag.ps 1 (concurrency)

Enterprise-расширение — это то, что корпоративный provisioning шлёт первым же запросом : Okta, Entra ID и Workday пушат department и manager на первой же синхронизации. Провайдер, который объявляет только core-схему, заставляет их выбросить эти данные.

SCIM-discovery endpoints ( /scim/v2/ServiceProviderConfig , ResourceTypes , Schemas ) реализованы unconditional unauthenticated — чтобы RP мог их пробовать ДО включения SCIM provisioning.

RFC 6238 TOTP

Time-based OTP, атомарность и защита от replay по §5.2 (секрет — 160 бит по RFC 4226 §4)

demo_mfa_ totp.ps 1

W3C WebAuthn Level 2 / FIDO2

Passkey enrollment + assertion, attestation=none/direct/indirect/enterprise , userVerification=preferred/required/discouraged

API готово, демо ждёт frontend-компаньона

NIST SP 800-63B / OWASP ASVS 4.0.3 §2.1

password policy (min 12, upper+lower+digit, history)

demo_password_change_ negatives.ps 1 13/13

Argon2id (OWASP 2023, primary)

password hashing 64MiB / 3 iterations / parallelism 4

enforced, upgrade-on-login для legacy BCrypt

SMS/Email OTP, recovery-коды (one-shot, помечаются использованными в той же транзакции, что и создание сессии) — demo_mfa_recovery_ codes.ps 1 . Disable / replace метод — demo_mfa_disable_ replace.ps 1 (DELETE-unauth → DELETE → re-enrol с проверкой S1 ≠ S2).

Внешние OIDC IdP (Google, Microsoft, Keycloak) и отдельно GitHub OAuth2 — провайдеры федерации с реальным provision-on-first-login и link-on-replay поведением.

Discovery federation_providers API

demo_ federation.ps 1

Redirect surface (302 → IdP)

demo_ federation.ps 1

Full callback round-trip против navikt/mock-oauth2-server (есть в dev docker-compose)

demo_federation_e2e.ps1

demo_federation_e2e.ps1

Link-on-replay (тот же sub → тот же user, без дубликата)

demo_federation_e2e.ps1

GitHub OAuth2-only path (не OIDC discovery) — против github.com , GitHub Enterprise, Gitea, self-hosted мок

demo_federation_ github.ps 1

Self-service link / unlink через /me/federated-identities/*

demo_federation_link_ unlink.ps 1

Email-conflict resolution (отказ от silent takeover)

demo_federation_link_ unlink.ps 1 step 3

LDAP / Active Directory — отдельный пакет redb.Identity.Ldap с bind-on-login и UserAccountControl-парсингом. Конфиг готов под OpenLDAP + AD-LDAPS пресеты.

Self-registration + email verify

demo_account_register_ verify.ps 1

Profile read/update, email change с dual-confirm

demo_me_ profile.ps 1 , demo_me_email_ change.ps 1

Password change (positive + 13 негативов), forgot-password round-trip

demo_password_change_ negatives.ps 1 , demo_password_ reset.ps 1

Sessions list / revoke own и admin (с dry-run)

demo_me_ sessions.ps 1 , demo_sessions_ admin.ps 1

Account delete (self) — каскадный revoke

demo_me_ delete.ps 1

OIDC client management (CRUD)

demo_dcr_ lifecycle.ps 1

Granular scope gate ( identity: audit.read ≠ identity:read ≠ identity:users.manage )

demo_admin_ scopes.ps 1 17/17

Group hierarchy + per-member role labels

demo_groups_roles_ claims.ps 1 16/16

JWKS rotation с live-refresh

demo_jwks_ rotation.ps 1 13/13

Emergency-admin bootstrap endpoint

BootstrapAdminEndpointTests 9/9

Отдельно отмечу DPoP (RFC 9449) и private_key_jwt (RFC 7521/7523) — это редкие в .NET-экосистеме вещи, и здесь они не «в роадмапе», а реализованы и покрыты демо-пробами.

Production-grade фичи — то, что у других «потом»

Раздел про неочевидные, но крайне важные в продакшене вещи.

Классическая проблема: вы выкатили OIDC-сервер, прошло 60 дней, надо ротировать signing key (кэш JWKS у RP'шников живёт до недели, плюс security-инциденты). У большинства имплементаций (включая базовый OpenIddict) ротация требует рестарта процесса , потому что OpenIddictServerOptions.SigningCredentials собирается один раз через IPostConfigureOptions<> и кэшируется.

У нас:

Админ дёргает POST /signing-keys/rotate (требует scope identity:applications.manage ).

Админ дёргает POST /signing-keys/rotate (требует scope identity:applications.manage ).

PropsSigningKeyStore.RotateAsync сохраняет новый ключ в storage, помечает старый как demoted (но он остаётся в JWKS до retire — grace window для in-flight токенов).

PropsSigningKeyStore.RotateAsync сохраняет новый ключ в storage, помечает старый как demoted (но он остаётся в JWKS до retire — grace window для in-flight токенов).

Сразу же инвалидируется IOptionsMonitorCache<OpenIddictServerOptions> и IOptionsMonitorCache<OpenIddictValidationOptions> .

Сразу же инвалидируется IOptionsMonitorCache<OpenIddictServerOptions> и IOptionsMonitorCache<OpenIddictValidationOptions> .

Следующий читающий обработчик OIDC re-evaluate'ит весь Configure → PostConfigure chain — PropsSigningKeyStoreOpenIddictPostConfigure перенакатывает SigningCredentials из актуального snapshot store'а.

Следующий читающий обработчик OIDC re-evaluate'ит весь Configure → PostConfigure chain — PropsSigningKeyStoreOpenIddictPostConfigure перенакатывает SigningCredentials из актуального snapshot store'а.

Новые токены сразу подписываются под just-rotated kid , in-flight токены валидируются под старым ключом до его retire.

Новые токены сразу подписываются под just-rotated kid , in-flight токены валидируются под старым ключом до его retire.

Демо demo_jwks_ rotation.ps 1 ассертит: после rotate JWKS отдаёт K1 + K2 (старый в grace + новый активный); новые id_token'ы несут именно just-rotated kid; после DELETE /signing-keys/{K1} retire JWKS содержит только K2, старые токены больше не валидируются; K1 остаётся в admin audit-list с inJwks=false (для compliance trail). Это полный grace-window flow без перезапуска.

Это identity-сервер, ему придётся жить в кластере. У нас встроены:

Optimistic concurrency на каждом RedbObject<T> через hash . UpdateAsync берёт row-lock + сверяет hash + бросает OpenIddictExceptions.ConcurrencyException при mismatch. Тест UpdateAsync StaleHash_ThrowsConcurrencyException гоняет именно этот сценарий с двумя параллельными write'ами.

Optimistic concurrency на каждом RedbObject<T> через hash . UpdateAsync берёт row-lock + сверяет hash + бросает OpenIddictExceptions.ConcurrencyException при mismatch. Тест UpdateAsync StaleHash_ThrowsConcurrencyException гоняет именно этот сценарий с двумя параллельными write'ами.

Cluster-wide lock для schema init. Когда несколько воркеров поднимаются одновременно, IdentitySchemaInitListener берёт LockForUpdate на specific row → «leader-under-cluster-lock» → инициализирует schemes → отпускает. Followers видят another node holds the lock, proceeding idempotently и ждут.

Cluster-wide lock для schema init. Когда несколько воркеров поднимаются одновременно, IdentitySchemaInitListener берёт LockForUpdate на specific row → «leader-under-cluster-lock» → инициализирует schemes → отпускает. Followers видят another node holds the lock, proceeding idempotently и ждут.

Atomic claim для background tasks. Trash purge / orphaned task recovery идут через TryClaimOrphanedTaskAsync — атомарный UPDATE с WHERE-condition. Какой воркер первым переключил статус pending → running — тот и обрабатывает. Distributed locking не нужен.

Atomic claim для background tasks. Trash purge / orphaned task recovery идут через TryClaimOrphanedTaskAsync — атомарный UPDATE с WHERE-condition. Какой воркер первым переключил статус pending → running — тот и обрабатывает. Distributed locking не нужен.

Per-request cache invalidation через DI scope. RedbApplicationStore._clientIdCache живёт ровно одно HTTP-обращение и инвалидируется на CRUD — никаких stale data между requests.

Per-request cache invalidation через DI scope. RedbApplicationStore._clientIdCache живёт ровно одно HTTP-обращение и инвалидируется на CRUD — никаких stale data между requests.

Удаление OIDC application с тысячами accumulated токенов и authorization'ов — дорогой cascade. Делать его в request thread — таймауты для пользователя. Делать через in-memory channel — теряется при crash воркера. У нас:

SoftDeleteAsync синхронно re-parent'ит объект под trash-scheme ( scheme_id = -10 ). Объект мгновенно пропадает из всех Query<>() . RP видит 204, операция «успешна».

SoftDeleteAsync синхронно re-parent'ит объект под trash-scheme ( scheme_id = -10 ). Объект мгновенно пропадает из всех Query<>() . RP видит 204, операция «успешна».

Background BackgroundDeletionService опрашивает БД на pending trash containers каждые 5 секунд. Cluster-safe ( TryClaimOrphanedTaskAsync ). При crash воркера следующий poll-цикл подхватывает.

Background BackgroundDeletionService опрашивает БД на pending trash containers каждые 5 секунд. Cluster-safe ( TryClaimOrphanedTaskAsync ). При crash воркера следующий poll-цикл подхватывает.

Purge идёт батчами по 10 объектов с Task.Delay(50ms) между — даёт live traffic вклиниваться.

Purge идёт батчами по 10 объектов с Task.Delay(50ms) между — даёт live traffic вклиниваться.

Без in-memory state — crash посреди операции не теряет ничего, БД остаётся source of truth.

Cookie-сессия (SameSite=Strict), federation state, MFA setup tokens, recovery code pepper — всё подписано/зашифровано через ASP.NET Core DataProtection. По умолчанию DataProtection хранит keys в файловой системе или Azure Storage — это не cluster-safe out-of-the-box и требует отдельной операционной заботы. У нас DataProtection key ring живёт в том же REDB-сторадже через RedbXmlRepository : бекапите Postgres — забэкаплен ключевой ring; все cluster nodes видят одинаковые ключи (rotate тоже стримится через REDB-уведомления); ключи at-rest зашифрованы конфигурируемой master-key (AES-GCM, certificate-based или custom KMS factory).

/connect/token , /login , /mfa/verify , /mfa/recovery — все throttled. Per-IP + per-(IP+username) счётчики. Backend в опциях: memory (single-node, in-memory bucket) или redis (cluster-wide через StackExchange.Redis, один Redis на rate-limit + federation state nonce store). 429 + Retry-After — точно по спеке (RFC 6585 §4 / RFC 7231 §7.1.3), демо demo_throttle_rfc6585.ps1 гоняет parallel burst, recovery после window, isolation между keys.

Раздел про детали, которые отделяют production-ready identity от учебного. Поддерживаем два алгоритма параллельно, без необходимости выбирать один на deployment level.

Argon2id — primary для новых deployment'ов. Параметры по умолчанию из OWASP Password Storage Cheat Sheet 2023 :

Memory-hard функция: 64 MiB за каждый verify делает GPU/ASIC-атаки экономически невыгодными по сравнению с BCrypt (compute-hard, но memory-cheap). Argon2id — гибрид Argon2i (side-channel) и Argon2d (time-memory trade-off), победитель Password Hashing Competition 2015.

BCrypt — для legacy и compliance-driven deployment'ов. BCrypt в production с 1999 года — auditor видит знакомый алгоритм с известными failure modes; PCI-библиотеки его прямо допускают. И migration story: если вы переезжаете с IdentityServer / ASP.NET Identity — у вас в БД уже BCrypt-хеши, мы их понимаем нативно, без forced password reset на 100k пользователей.

Upgrade-on-login — прозрачная миграция. Каждый успешный login проверяет: совпадает ли формат хранимого хеша с current OWASP-параметрами. Если нет — fire-and-forget rehash в свежий формат:

Через N логинов вся база переезжает на Argon2id без принудительного password reset и без миграционного окна.

Constant-time fake-verify — против user-enumeration через timing. ValidateUserAsync возвращает null для трёх режимов: пользователь не найден / disabled / wrong password. Без mitigation (1) и (2) отвечают за 5–10ms (DB-lookup миссит сразу), а (3) — ~250ms (BCrypt verify реально гоняется), и атакующий по времени различает «есть юзер или нет» (CWE-204). Precomputed FakeBcryptHash (workFactor 12) гоняется на каждый negative-сценарий → wall-clock одинаковый для всех трёх случаев:

Password policy — NIST SP 800-63B / OWASP ASVS 4.0.3 §2.1 (до хеширования):

RequireDigit / RequireUppercase / RequireLowercase

true

false

HistoryCount (нельзя повторно использовать последние N)

MaxAge (force rotation)

90 дней

BreachCheckEnabled (haveibeenpwned probe)

false

HistoryCount хранится в PropsPasswordHistoryStore — SHA-256 hashed prior-password digests с peppered hash'ом в REDB (закрывает «пользователь меняет пароль и через час возвращается на старый»). Demo demo_password_change_ negatives.ps 1 гоняет все 13 негативных путей.

Аудит — «куда угодно», доказано интеграционными тестами

Аудит — не «прикрутим потом», а первый класс. 116 типизированных событий в 9 категориях с единым источником правды в IdentityAuditEventIds . Каждое событие ложится в реляционную таблицу identity_audit_log ( user_id BIGINT , индексированный — прямой integer-seek, а не скан) и, если настроено, мультикастится во внешние приёмники. Плейнтекст-секреты в аудит не пишутся — ротация оставляет только маркер ClientSecretRotated .

authentication

UserLoggedIn , LoginFailed , PasswordChanged

authorization

TokenIssued / Revoked / Introspected , ConsentGranted , DpopReplayDetected

admin

Client* , Scope* , User* , Group* , ClaimMapper*

federation

FederatedUserLoggedIn , FederationStateValidationFailed , FederatedEmailConflict

mfa

MfaEnrolled , MfaVerifyFailed , MfaWebAuthnSignCounterAnomaly

scim

ScimUserCreated / Replaced / Patched , ScimBulkProcessed

system

SessionRevoked , SidRevoked , TokensRevokedByUser

Первые кандидаты в SIEM: LoginFailed , MfaVerifyFailed , MfaWebAuthnSignCounterAnomaly , DpopReplayDetected , FederationStateValidationFailed , ClientSecretRotated , AllSessionsRevoked .

Каждое мутирующее действие (login, token issue, scope grant, MFA enroll, federation link, user delete, …) проходит через EventDispatchProcessor , который пишет типизированный IdentityEvent в exchange.Out.Body + headers. Дальше — стандартный redb.Route WireTap:

identity-events маршрут можно роутить в любое количество sinks одновременно — без переписывания identity-кода , это конфиг роутов.

«Куда угодно» — не маркетинг. 9 разных audit-таргетов имеют собственные интеграционные тесты в redb.Identity.Tests/Audit/ , гоняются против реальных контейнерных broker'ов в dev compose stack:

Что проверяет

SQL (Postgres / MS SQL audit table)

event с правильными колонками, parameter binding через redb.Route SQL connector

PROPS storage (типизированное хранилище в той же REDB)

AuditEavPersistenceIntegrationTests

IdentityEvent как RedbObject<AuditEventProps> рядом с identity-данными — query'абельно

AuditKafkaIntegrationTests

publish в topic, partition key из user_id / event_type, acks=all

AuditRabbitMqIntegrationTests

publish в exchange, routing key из event-type, durable queue с manual ack

IBM MQ / WMQ

AuditIbmMqIntegrationTests

publish в MQQueue, MQMD с correlation-id, transactional context

AMQP 1.0

publish в standard AMQP node, properties + application-properties + body

publish в topic с QoS 1+, retained flag (audit-stream для IoT-gateway)

AuditElasticsearchIntegrationTests

bulk indexing в identity-events-* , готово для Kibana

AuditRedisIntegrationTests

publish в pub/sub (live firehose) + опциональный append в Stream (replay-capable history)

Плюс AuditEventSinkProcessorTests — unit-тесты на сам fan-out процессор. Плюс AuditCompletenessFullCycleTests в FullStack/ — full-cycle прогон: совершили N мутирующих действий через HTTP → проверили, что каждое долетело до настроенных sink'ов. То есть «audit идёт куда угодно» означает: CI на каждом commit'е гоняет 8 реальных приёмников (Kafka, RabbitMQ, IBM MQ, AMQP, MQTT, Elasticsearch, Redis, SQL) и проверяет, что events корректно сериализуются и долетают. Не «архитектурный слайд», а зелёный билд против реальной инфраструктуры.

Один identity-сервер, один событийный источник ( direct-vm://identity-events ), пять параллельных sink'ов разной природы. Никакой identity-сервер на рынке так не умеет из коробки — у всех либо «pick one sink в config'е», либо «webhook на ваш HTTP endpoint, делайте fan-out сами». Это и есть транспорт-агностичность на уровне событий: IBM MQ для банков (которые по compliance не могут уйти с WMQ), RabbitMQ для general-purpose, Kafka для streaming-аналитики — identity использует тот же транспортный набор, что и любой другой бизнес-маршрут.

Метрики — стандартный OpenTelemetry-meter RedbIdentity , подключается в любой OTel-пайплайн:

Внутри — счётчики логинов/отказов (тег reason ), MFA-верификаций ( method , result ), выданных токенов ( grant_type , token_type ), ошибок токенов ( error ), rate-limit-отклонений и гистограмма времени verify пароля (ловит регрессии CPU на хешировании). Плюс отдельный security-канал логирования ( RedbIdentity.Security ), чтобы SIEM подписывался на audit-grade события, не просеивая рутинные логи. И health-пробы модуля identity ( db , signing-keys , data-protection ) под агрегированным /api/health/{startup,live,ready} Tsak.

Logical-level backup через redb.Export — без pg_dump, без вендор-локов

Секция, которая обычно остаётся «на потом» и потом неожиданно становится проблемой. У большинства identity-серверов backup-story сводится к одному из двух: pg_dump / mssqldump (бинарный snapshot — нельзя restore'ить subset, привязан к версии БД, миграция Postgres → MSSQL невозможна) или application-level export через admin API (обычно поверхностный, без FK-порядка, не покрывает custom-схемы).

У нас отдельный пакет — redb.Export — делает logical-level dump в портативный .redb формат:

JSONL stream (newline-delimited JSON), опционально в ZIP.

JSONL stream (newline-delimited JSON), опционально в ZIP.

Foreign-key-safe order — типы → роли → пользователи → user_roles → списки → list_items → схемы → структуры → объекты → permissions → values. Single-pass restore.

Foreign-key-safe order — типы → роли → пользователи → user_roles → списки → list_items → схемы → структуры → объекты → permissions → values. Single-pass restore.

Filter by scheme subset — --schemes UserProps,ApplicationProps . Восстановить тоже можно частично.

Filter by scheme subset — --schemes UserProps,ApplicationProps . Восстановить тоже можно частично.

Counts по типам — заголовок знает, сколько записей каждого типа было экспортировано, footer ассертит соответствие.

Counts по типам — заголовок знает, сколько записей каждого типа было экспортировано, footer ассертит соответствие.

Multi-provider — IDataProvider (Postgres / MSSQL / SQLite). Один .redb переносим между движками — типизированная семантика сохраняется поверх dialect-разницы.

Multi-provider — IDataProvider (Postgres / MSSQL / SQLite). Один .redb переносим между движками — типизированная семантика сохраняется поверх dialect-разницы.

dryRun — посчитать, что было бы выгружено, без записи на диск.

dryRun — посчитать, что было бы выгружено, без записи на диск.

Реальное использование из боевого проекта. Файл TsumBackupRouteBuilder.cs :

Никаких отдельных backup-сервисов, Bash cron-job'ов, отдельных deployment'ов: один From("cron://...") (Quartz уже встроен в route system), .ProcessWithRedb(...) инжектит named-scope IRedbService , ExportService.ExportAsync(..., compress: true, ...) включает компрессию аргументом, RotateBackups — простой retention по дате. Файл вставлен в домен проекта одной строкой context.AddRoutes(new TsumBackupRouteBuilder()) . Identity получает тот же backup механизм автоматически — потому что Identity-данные живут в той же REDB.

Один из killer-сценариев — мигрировать identity-стек между движками без переписывания кода (за три команды CLI):

.redb типизированный и кросс-провайдерный — dialect-различия инкапсулированы в IDataProvider . Identity после миграции видит ровно тот же набор данных: те же OpenIddictApplication -id, те же sub пользователей, те же signing keys. Это уровень портативности, которого нет ни у IdentityServer (привязан к EF-провайдеру и migrations), ни у Keycloak (Java SPI ↔ Postgres/MariaDB), ни у Auth0 (нет on-prem вообще).

Тот же pipeline доступен из терминала через redb.CLI (на nuget.org ):

Четыре команды покрывают весь identity-операционный цикл:

redb init — bootstrap REDB-таблиц/sequences/functions/views в чистой БД ( -p postgres|mssql|sqlite ). Аналог «migrations init», но на один раз — дальше схема живёт через scheme-registry.

redb init — bootstrap REDB-таблиц/sequences/functions/views в чистой БД ( -p postgres|mssql|sqlite ). Аналог «migrations init», но на один раз — дальше схема живёт через scheme-registry.

redb schema — эмиссия SQL-скрипта для DBA review ( redb schema -p postgres -o redb_schema.sql или | psql -d mydb ). Полезно для change management (PR-флоу), CI/CD DDL-артефакта, compliance review без поднятия БД.

redb schema — эмиссия SQL-скрипта для DBA review ( redb schema -p postgres -o redb_schema.sql или | psql -d mydb ). Полезно для change management (PR-флоу), CI/CD DDL-артефакта, compliance review без поднятия БД.

redb export — backup в .redb ( --compress , --schemes 100,200,300 , --dry-run ).

redb export — backup в .redb ( --compress , --schemes 100,200,300 , --dry-run ).

redb import — restore из .redb ( --clean для truncate-before, --dry-run для проверки совместимости).

redb import — restore из .redb ( --clean для truncate-before, --dry-run для проверки совместимости).

Для identity-кейсов это даёт: compliance audit retention (шифрование .redb на уровне FS/KMS, retention N лет), disaster recovery testing (restore в staging для DR-drill без сложного pg_restore), forensic snapshot at incident (один cron-trigger через direct-vm:// — снимок есть), schema-subset export для регулятора, cross-version migration. Это те фичи, которые в облачных IDP стоят отдельных Enterprise-планов; здесь — first-class часть архитектуры.

Host-agnostic — Tsak это опция, а не зависимость

Частый вопрос: «вы говорите про hot-reload, кластер, дашборд — это всё обязательно?». Нет. Identity-сервер — это набор redb.Route-маршрутов; чем именно они хостятся, ему всё равно.

Опция 1 — обычный .NET worker. Ваш Program.cs на Host.CreateApplicationBuilder :

dotnet run — поднялся OIDC-сервер. Никаких контейнеров runtime, дашбордов, .tpkg . Деплой как обычный Worker Service / systemd unit / Windows service / Docker.

Опция 2 — внутри ASP.NET Core monolith'а. Identity встраивается в тот же процесс, получает доступ к IServiceProvider хоста (DataProtection key ring, IConfiguration , ILogger ) — повторная инициализация не нужна.

Опция 3 — service-mesh sidecar. Identity как in-process compliance-капсула внутри каждого микросервиса. Service-A выписывает тенант-локальные токены через direct-vm://identity-token без сетевого хопа. Никакого Tsak — достаточно services.AddRedbIdentity(...) .

Опция 4 — redb.Tsak для enterprise control plane. Когда identity живёт в Tsak, добавляется управленческий слой, который иначе пришлось бы писать руками:

Что даёт Tsak

Применительно к identity

Hot-reload одного .tpkg без потери in-flight сообщений в остальных контекстах

Обновили redb.Identity.Core.Module.tpkg (новая redirect_uri-валидация) — другие маршруты (Kafka consumers / audit pipeline / federation callbacks) не теряют сообщения

Multiple named contexts в одном процессе

Identity рядом с orders / payments / analytics — каждый изолирован через свой AssemblyLoadContext

Blazor Server дашборд + REST API

Operator видит все identity-routes, метрики per-route, ring-buffer logs, может остановить один маршрут (например /connect/register под атакой) не трогая остальные

CLI ( tsak route stop , tsak context stop orders , …)

CI/CD-пайплайны без custom kubectl-обвязки

REST-эндпоинты ( /api/contexts , /api/modules , /api/routes , /api/cluster , /api/logs , /api/watchdog , /api/scheduler )

Свой ops-портал через типизированный ITsakApiClient SDK

Cluster mode с leader election + redistribution контекстов

Реплика падает — Tsak перенаправляет её контексты на здоровые ноды

Quartz IScheduler в каждом context

Token cleanup, session expiry, JWKS auto-rotation, audit retention — на стандартном cron'е

Watchdog детектит hung routes

Federation callback застрял на 30s — Tsak перезапустит, без manual ops alert'ов в 03:00

Prometheus /metrics + OTLP/Jaeger traces

Per-token-issue latency p50/p95/p99 + per-route error rate в Grafana, трейсы в Jaeger

API Key + HMAC-SHA256 + roles + expiry + revocation для admin API

Identity admin endpoints (например /signing-keys/rotate ) защищены тем же auth-слоем, что и управление маршрутами

5-layer config hot-reload

Изменили Identity.RateLimit.PerIpPerMinute в context.json — подхватилось без рестарта

Tsak имеет смысл, когда у вас десятки независимых pipeline'ов и identity — один из них; operator-команда хочет браузерный дашборд вместо kubectl exec ; identity живёт в кластере 3+ нод; нужен hot-reload без окон даунтайма; хотите затащить свои расширения ( IModuleProvider , IRouteLifecycleListener , watchdog-стратегию — обычные интерфейсы в redb.Tsak.Core ). Не нужен — когда один сервер с рестартом, ASP.NET -монолит с identity внутри или один микросервис-sidecar.

Identity-стек не знает о Tsak'е. В redb.Identity.Core нет ни одного using redb.Tsak . Спектр деплоя:

Расширение рядом с identity — своя схема и свои маршруты

Identity-сервер не «закрытая коробка» — он живёт в той же REDB и в том же IRouteContext , что и любой ваш бизнес-маршрут. Это значит: объявляете свой типизированный prop-класс, регистрируете схему рядом с identity-схемами (без ALTER TABLE), получаете полноценный CRUD + LINQ через тот же IRedbService , и WireTap'аете свои маршруты на identity-events :

Никаких extension points, SPI, plugin manifest'ов и rebuild'а identity-сервера — это обычный C#-код в обычной сборке, который сосуществует с identity-стеком в одном процессе и одной БД. Хотите — добавляете в Tsak как отдельный .tpkg (деплоится независимо). Хотите — компилируете вместе с identity в один worker.

Это сильнее , чем просто WireTap. Identity-маршруты публикуются как direct-vm://identity-token , direct-vm://identity-authorize , direct-vm://identity-users-delete , … — все стандартные endpoint URI ( IdentityEndpoints.Token , .Authorize , …, полный список в IdentityEndpoints.cs ). Значит addon вызывает identity ровно как свою же библиотеку — через message-passing, но внутри одного процесса без сетевого хопа.

Пример: addon регистрирует пользователя в организации и одновременно выписывает ему onboarding-token через identity-сервер, всё в одной транзакции:

Что здесь произошло: один маршрут делает enrollment в своей схеме + token issue в identity-стеке + email notification последовательно, в одной exchange-цепочке; WithRedbTx(...) обнимает всё — и save в OrgEnrollmentProps , и identity'шный token issue (он тоже пишет в _objects через RedbTokenStore ) — один redb-tx, atomic commit; никакого HttpClient , serialization в JSON, TLS handshake; тот же correlation-id проходит насквозь. Любой direct-vm endpoint identity доступен addon'у как in-process call — не «обёртки SDK», а сами endpoint'ы.

Для сравнения: чтобы выписать token из Java-кода рядом с Keycloak, вы обязаны написать обёртку (HttpClient → form-data → HTTP → JSON-back → retry/circuit-breaker → SDK-классы → поддерживать SDK при обновлении Keycloak). Каждая обёртка — точка отказа, точка сериализации, точка отдельной транзакции (atomic semantics между «выписали token» и «записали в свою БД» невозможны), точка несинхронной версии, точка отдельного теста. У нас — одна строка .To(IdentityEndpoints.Token) и atomic commit через WithRedbTx . Эта разница и объясняет, почему люди вообще пишут собственные identity-сервера: коробочные не дают «использовать identity как библиотеку рядом с бизнес-логикой».

HTTP-фасад — это просто один из коннекторов поверх direct-vm-ядра. Никто не мешает поставить свой фасад на корпоративный/проприетарный протокол. Identity-ядру всё равно — он принимает сообщения через direct-vm:// , а как они туда попадают — ваше дело:

Корпоративный binary RPC (свой формат поверх TCP, cert-pinning, custom framing)

From("custom-rpc://0.0.0.0:9999") → разбираете frame → To(IdentityEndpoints.Token)

gRPC-фасад

From("grpc://.../oidc.IdentityService/IssueToken") → mapping в headers → To(IdentityEndpoints.Token)

MQTT для IoT (устройства не умеют HTTP/2)

From("mqtt://broker:1883/device/+/token-request") → forward в device_code grant

Внутренний enterprise message bus (WMQ как единственный transport в банковском контуре)

From("wmq:// QM.PROD ?queue=IDENTITY.TOKEN.REQ") → forward → ответ в IDENTITY.TOKEN.RESP

Свой шифровальный wrapper (token-request в custom crypto-envelope)

.Process(DecryptEnvelope) → To(IdentityEndpoints.Token) → .Process(EncryptEnvelope)

Любой коннектор redb.Route может выступать фронтом identity-сервера. Хотите token'ы из RabbitMQ-очереди? Один RouteBuilder. Slack-bot как identity admin API? Один RouteBuilder.

Отдельный управляющий фасад для admin-эндпоинтов на публичном периметре

Сценарий, который редко проговаривают: identity-сервер обязан стоять на публичном IP (cloud edge, partner-API, multi-region). Универсальный VPN/mTLS поверх него не натянуть: реальные RP-клиенты ходят с HTTPS из браузеров и mobile; внешний federation IdP callback'ает на ваш /connect/federation/callback ; backchannel logout требует publicly resolvable URL. То есть стандартные OIDC endpoints ( /connect/* ) обязаны торчать публично.

Но критические admin endpoints — /signing-keys/rotate , /users/{id}/force-revoke , /internal/bootstrap-admin , mutate-операции над applications — не обязаны быть HTTP. И здесь можно выставить их через отдельный фасад на своём проприетарном TCP-framing с custom magic-bytes, версией протокола, нестандартными маркерами — на отдельном порту и в отдельной сети:

Это не замена крипто-защите — auth + HMAC + role-check на месте на каждый opcode. Это дополнительный слой defense-in-depth, работающий не на крипто, а на сокращении attack surface и information asymmetry : стандартные exploit-toolkit'ы (Burp, sqlmap, msf) и bot-net-сканеры не понимают custom framing и не сканируют кастомные TCP-протоколы; атакующему нужно сначала реверс-инженерить формат, прежде чем он сможет сформировать даже синтаксически валидный запрос. Каждый день этой задержки — день на обнаружение и реакцию у security-команды. Паттерн давно известен банкам, военным и госсектору (свои RDBMS-protocols, своя сериализация в межсервисных каналах); раньше он требовал переписывания стека под proprietary, теперь — один RouteBuilder перед стандартным OIDC-ядром. Стандартные RP продолжают ходить по /connect/* поверх HTTPS — для них ничего не меняется.

Свобода расширения — системное свойство всей экосистемы:

Точки расширения

REDB storage

IRedbObjectStorageProvider (другой бэкенд под _objects ), ISqlDialect , IPasswordHasher , IPropsSaveStrategy

redb.Route

свой коннектор ( From("myprotocol://...") ), IProcessor , custom EIP, IDataFormatRegistry , IExpression

redb.Identity

IExternalUserProvider , IPasswordHasher , IPasswordBreachChecker , IRateLimitStore , IEmailNotificationChannel , IBackgroundDeletionService , IClaimMapper — плюс любой custom RouteBuilder как peer-маршрут

redb.Tsak

IModuleProvider , IRouteLifecycleListener , IWatchdogStrategy , ITsakAuthProvider

redb.Route.Controllers

IControllerDispatcher , IActionResultMapper

Везде один паттерн: публичный интерфейс с понятным контрактом + DI-registration + ваша реализация. Зарегистрировали свою реализацию — identity подхватил, не заметив подмены. Никаких SPI-manifest'ов и vendor-lock-in моментов.

Поставьте рядом с identity LLM-коннектор ( redb.Route.Llm — Anthropic / OpenAI / Azure OpenAI), Kafka stream identity-events и свои доменные схемы. Пароли при этом остаются Argon2id-хешированными в БД — LLM физически не видит plaintext (в промпт уходит IdentityEvent : event-type , user-id , client-id , ip-address , timestamps; чувствительный материал отфильтрован в EventDispatchProcessor до WireTap). Что получается — очередной .To() / .Filter() / .Process() в вашем RouteBuilder , а не отдельный продукт за доп-лицензию:

AI-аномалия-детектор на login-паттерны

From(IdentityEvents).Filter(type=="LoginSuccess").To("llm://anthropic?prompt=...") → risk-score → direct-vm://identity-sessions-revoke при превышении

Real-time fraud scoring на token-issue

From(IdentityEndpoints.Token).WireTap(To("llm://...")) → score > threshold → revoke + flag-for-review

Conversational admin через Telegram/Slack

From("telegram://admin-bot") → LLM-агент с .AsLlmTool() -обёртками над admin endpoint'ами → «отозвать все сессии foo@acme.com » одним сообщением

LLM-классификация audit-events

From(IdentityEvents).To("llm://claude?prompt=Classify severity...") → To("kafka://security-incidents-prioritised")

Auto-compliance reports на квартал

From("cron://quarterly-report") → To("postgres://identity_audit?query=...") → LLM-агрегация → .To("email:// compliance@org.com ")

LLM-валидация redirect_uri при DCR

новый OIDC client → .To("llm://...?prompt=Is this redirect_uri benign?") → human-in-the-loop для подозрительных

У вас уже есть identity с direct-vm endpoint'ами, LLM-коннектор с .AsLlmTool() , Kafka/RabbitMQ для streaming, Tsak для деплоя и свои RouteBuilder 'ы — скомбинировать это в pipeline'ы дело часов, а не отдельного эпика на квартал.

Enterprise scale — тысячи микросервисов и сотни тысяч пользователей

Перечисленные фичи (transport-agnostic ядро, schemes as data, cluster-safe primitives, JWKS live-refresh) нужны ровно потому, что современная enterprise-инфраструктура выглядит так: тысячи микросервисов по client_credentials и token-exchange delegation chains; сотни тысяч живых сессий (mobile + web + thick clients) с параллельными federation round-trip'ами; несколько датацентров с identity-replicas под LB; регуляторные требования, запрещающие данным идентичности уезжать за периметр.

Классика: service-A → POST /connect/token → identity (HTTP) , затем service-B → POST /connect/introspect → identity (HTTP) . Это два сетевых хопа к identity на каждый внутренний вызов — на 10K RPS internal traffic identity становится bottleneck + single point of failure + добавляет p99-latency. У нас:

Embedding mode. redb.Identity.Core подключается как in-process библиотека в каждый микросервис, client_credentials issue идёт через direct-vm://identity-token — zero network hop.

Embedding mode. redb.Identity.Core подключается как in-process библиотека в каждый микросервис, client_credentials issue идёт через direct-vm://identity-token — zero network hop.

JWT validation без introspect. Resource-серверы валидируют bearer локально через redb.Identity.Resource.Dpop (или ванильный JwtSecurityTokenHandler для не-DPoP). JWKS подсасывается через IConfigurationManager<OpenIdConnectConfiguration> с долгим TTL — сетевой запрос раз в час, не на каждый bearer.

JWT validation без introspect. Resource-серверы валидируют bearer локально через redb.Identity.Resource.Dpop (или ванильный JwtSecurityTokenHandler для не-DPoP). JWKS подсасывается через IConfigurationManager<OpenIdConnectConfiguration> с долгим TTL — сетевой запрос раз в час, не на каждый bearer.

Token exchange (RFC 8693) для delegation chains. Service-A получает токен пользователя, дёргает service-B от его имени → service-B видит act claim chain (RFC 8693 §4.1) с историей делегаций. Демо demo_token_ exchange.ps 1 .

Token exchange (RFC 8693) для delegation chains. Service-A получает токен пользователя, дёргает service-B от его имени → service-B видит act claim chain (RFC 8693 §4.1) с историей делегаций. Демо demo_token_ exchange.ps 1 .

Identity спроектирован под N-реплик, где лидерство ротируется без внешней координации:

Stateless replicas

все handler'ы Scoped, no static state — любая реплика обрабатывает любой запрос

DataProtection key-ring между репликами

RedbXmlRepository — ключи в redb; каждая реплика обновляет снапшот каждые XmlRepositoryRefreshInterval (60с). Не cluster-gated — каждый узел обязан подхватывать отротированные другими

OAuth signing keys между репликами

redb-хранилище RSA 2048 PEM, зашифрованы DataProtection'ом, бутстрап под распределённым локом (первый ключ генерит одна реплика)

Federation state nonce / rate-limit counters

Redis backend, cluster-wide (RFC 6749 §10.12 state validation / RFC 6585 §4)

Schema init

cluster-wide lock через LockForUpdate — leader инициализирует, followers ждут

Background trash purge

TryClaimOrphanedTaskAsync — любая реплика подхватит orphan'а после краха другой

Signing key rotation

IOptionsMonitorCache.TryRemove инвалидирует кэш на всех репликах

Backchannel logout между репликами

/revoked-sids/add пишет отзыв; /revoked-sids/since?cursor= даёт каждой реплике RP тянуть дельты. Push-and-poll — переживает потерю узлов и разрывы сети

Никакого ZooKeeper, никакого отдельного стораджа для ключей — всё это redb-объекты. В трёх-нодном кластере под нагрузкой ~5K /token RPS identity-стек работает с p99 в районе 50–80ms (Pro-path с PVT-индексами по value_string для client_id lookup).

Self-hosted = identity-данные физически живут на вашем оборудовании. Никакого callback'а в третий процессинг-центр, никакой передачи имейлов/телефонов на cloud-vendor. Критично для банков (KYC на регулируемом контуре), health/medical (HIPAA / GDPR Article 9), госсектора (data-residency), B2B-SaaS-on-prem (клиент деплоит в своём VPC). redb.Identity не звонит домой — никакой телеметрии, pings, update-checks. Один Postgres + один .NET worker + ваша сеть.

Классический OIDC multi-tenant — /{tenant}/connect/token , отдельные DB-schemas, отдельные key rings — требует tenant routing в каждом handler'е, tenant-aware migrations, per-tenant key rotation. У нас изоляция возможна на уровне схем REDB: разделение физическое через id scheme , query от tenant A не пересекается с данными tenant B даже через ошибочный фильтр. Это пока возможность , не отдельная фича: production-deployments обычно стартуют single-tenant и докатывают multi-tenancy через tenantId claim — приоритизируем при инбаунд-спросе.

Живые демо: около 61 проб по реальному серверу

Помимо xUnit-набора, в репозитории лежит demos/ — 61 самодостаточная PowerShell-проба , которые гоняют живой сервер по его настоящему HTTP-контракту. По одной demo_*.ps1 на протокольный контракт: скрипт сам поднимает клиента (через DCR), прогоняет flow end-to-end и ассертит результат на уровне провода (статус-коды, заголовки, форма токена/JWKS, отклонение replay). Это одновременно исполняемая документация RFC и чёрно-ящичный регресс-нет.

run_ all.ps 1 запускает каждую пробу в своём дочернем процессе pwsh (жёсткое падение одной не рушит остальные), стримит вывод, пишет транскрипт в demos/_logs/ и печатает итоговую таблицу pass/fail. Карта проб по категориям:

Единственная проба, требующая ручного интерактива, — device-code consent (для CI есть неинтерактивный demo_device_code_ ci.ps 1 ).

Кластер: реплики без лишней инфраструктуры

Identity спроектирован под N-реплик, где лидерство ротируется без внешней координации (детально — в разделе про enterprise scale). Коротко:

DataProtection key-ring между репликами

RedbXmlRepository — ключи в redb, снапшот каждые 60с, не cluster-gated

OAuth signing keys между репликами

redb-хранилище RSA 2048 PEM, зашифрованы DataProtection'ом, бутстрап под распределённым локом

Cleanup-таймеры (токены/сессии/OTP/челленджи/revoked SID)

.Cluster(true) — leader-only в кластере, тихо игнорируются в standalone; плюс claim-паттерн

Backchannel logout между репликами

/revoked-sids/add + /revoked-sids/since?cursor= — push-and-poll, переживает потерю узлов

Никакого ZooKeeper, никакого отдельного стораджа для ключей — всё это redb-объекты.

Федерация: OIDC и GitHub как redb-объекты

Внешние провайдеры — не хардкод в конфиге, а redb-объекты с admin CRUD. Поддержаны OIDC-провайдеры и отдельно GitHub OAuth2 (у него нет discovery, нет id_token , профиль и почты тянутся из REST /user и /user/emails — для него сделан отдельный адаптер поверх общего federation-механизма, с настраиваемыми эндпоинтами под GitHub Enterprise / Gitea). Провижининг при первом входе, линковка при повторном (без дублей), self-service линковка/анлинковка через /me/federated-identities . Плейсхолдер-провайдеры ( ClientId=REPLACE_ME ) прячутся с /login и из публичного списка, чтобы свежий деплой не вёл пользователя на «OAuth client was not found».

Референсный BFF + админка на Blazor в комплекте

redb.Identity.Web — референсное Blazor Server приложение, демонстрирующее HARDLINE-паттерн BFF:

ссылается только на redb.Identity.Contracts + redb.Identity.Client — никакого Core , никакого Http ;

ссылается только на redb.Identity.Contracts + redb.Identity.Client — никакого Core , никакого Http ;

все вызовы Identity идут через типизированный IIdentityClient — никогда не через сырой HttpClient ;

все вызовы Identity идут через типизированный IIdentityClient — никогда не через сырой HttpClient ;

OIDC Authorization Code + PKCE, SaveTokens=true , backchannel-logout sink + poll-fallback для мульти-реплики.

OIDC Authorization Code + PKCE, SaveTokens=true , backchannel-logout sink + poll-fallback для мульти-реплики.

Страницы покрывают и self-service ( /Me/* : профиль, пароль, MFA/TOTP, WebAuthn-креды, сессии, федеративные связки, согласия), и админку ( /Admin/* : пользователи, группы-деревом, приложения, скоупы, claim-mappers, роли, federation-провайдеры, сессии, токены, аудит с JsonViewer, SCIM-браузер, настройки). Это едет исходником в репозитории, не пакетом — клонируешь и запускаешь, или берёшь как отправную точку.

Ну и типизированный SDK для тех, кто зовёт Identity снаружи:

Каждая HTTP-ошибка нормализуется в RFC 7807 ProblemDetails и всплывает как ApiException .

Немного сравнений (честно, без агрессии)

ASP.NET -привязанный IS (Duende, сэмплы OpenIddict)

redb.Identity

Standalone IAM (Keycloak / Auth0)

Вызвать token из соседнего модуля в процессе

To("direct-vm://identity-token") — тот же exchange, zero-copy

Сетевой хоп

Добавить транспорт (gRPC, MQ, SignalR)

Переписать эндпоинты как gRPC-сервисы / консьюмеры

Положить фасад .tpkg , указать на direct-vm://

Вендорский адаптер (если есть)

Заменить HTTP на RabbitMQ целиком

Капитальный рефакторинг

Выкинуть HTTP-фасад. Оставить Core.

Кастомная EF-схема + миграции

redb RedbObject<TProps> — код-first, без миграций

Вендорская схема, вендорские миграции

Кастомные claims

jsonb -блоб + свои индексы

Dictionary на props — запросимо нативно

Вендорская модель атрибутов

Шаринг ключей на мульти-реплику

DIY DataProtection + JWKS

Из коробки: redb key-ring + signing key store

Из коробки (по-вендорски)

Встроить в свой воркер как библиотеку

Да — один-два .tpkg или прямой вызов Core

Один на сборку

PG / MSSQL / SQLite из одного кода

Addon как peer-сервис в той же БД/транзакции

Controllers рядом, но storage отдельный (EF)

Своя схема + direct-vm вызовы + один redb-tx

Только снаружи, через REST

Смешанная (Duende — коммерческая)

Apache 2.0

Пара честных оговорок, чтобы без передёргивания. Keycloak/Auth0 — это готовый продукт с админ-UX, мультиарендностью и экосистемой, которую мы не воспроизводим один-в-один; если вам нужен «поставил коробку и не думаешь» — это по-прежнему валидный выбор. Duende IdentityServer — зрелейший .NET-стек с огромным комьюнити; наш аргумент не «он плохой», а «он архитектурно привязан к HTTP-пайплайну, и это не всегда то, что нужно». По части социальных провайдеров из коробки (Apple Sign-In, LinkedIn, X) Auth0/Okta впереди — мы поддерживаем generic OIDC + GitHub OAuth2, остальное пишется как federation-провайдеры (есть mock-provider в demo_federation_e2e.ps1 , показывающий как). Это честное ограничение, не блокер. redb.Identity выигрывает именно там, где identity должен быть встроенной частью вашей .NET-системы, а не сервисом-соседом, и где не хочется тащить миграции и вторую модель хранения.

Мы знаем, чего у нас нет, и вот почему это ровно то, чего у нас и должно не быть на этом этапе.

Список, проверяемый: всё, чего тут нет, наш /.well-known/openid-configuration подтвердит или опровергнет за один curl .

Не реализовано: RFC 8705 (mTLS + cert-bound токены — tls_client_certificate_bound_access_tokens: false ), RFC 9101 (JAR — request objects не потребляются, на request= отвечаем request_not_supported ), RFC 8707 (Resource Indicators), RFC 9396 (RAR), RFC 9470 (step-up), FAPI 2.0, CIBA, OIDC Federation 1.0. Из OIDC Core нет pairwise- sub (только public ) и, по OAuth 2.1, нет implicit и hybrid флоу.

Два пункта мы осознанно решили не делать — и это важнее, чем галочка в таблице:

Front-Channel Logout 1.0. Он разлогинивает RP через iframe к каждому клиенту — то есть держится на сторонних куках . Safari ITP их режет, Chrome их хоронит. Механизм сломан by design в современных браузерах, и любой, кто ставит на него галочку, обязан рядом писать «работает не везде». У нас есть Back-Channel Logout — сервер-к-серверу, с подписанным logout-токеном и pull-лентой отозванных sid для мульти-реплики RP. Он строго лучше и от кук не зависит. Мы предпочли рабочий механизм галочке.

HOTP (RFC 4226) как отдельный метод. Counter-based OTP в 2026 практически никто не использует: везде TOTP. RFC 4226 живёт как фундамент TOTP — и в этом качестве у нас он и есть (160-битный секрет по §4). Городить отдельный счётчик с resync-окном ради строчки в каталоге — это работа ради каталога, а не ради пользователя.

Планируемые фасады (тот же .tpkg -паттерн, без изменений Core): redb.Identity.Grpc , .Rmq / .Amqp / .IbmMq , .SignalR , .Kafka (event-only sink). PR приветствуются.

Быстрый старт

Самый быстрый способ — Docker (SQLite + HTTPS, из коробки). Готовый образ — рабочий OpenID-провайдер, без единой настройки:

Образ несёт self-signed dev-сертификат ( CN= localhost ) — для локальной пробы зовите с curl -k . Варианты: backend (только OIDC), managed (+ Tsak-дашборд), full (+ BFF-логин/админка на :8087 ). compose-стеки и DOCKER.md — в publish/docker/ . Образы подписаны cosign.

Не любите Docker? Со страницы releases — self-contained архив (Worker + модули, тоже SQLite + HTTPS из коробки, cosign-подпись): распаковать и запустить start-full.ps 1 / start-full.sh .

А для встраивания в свой проект — пакеты уже на nuget.org . Берёте то, что нужно — провайдер выбирает хост:

Или собрать .tpkg -модули из исходников и уронить в Tsak-воркер:

Полный dev-setup с реальной инфраструктурой:

Проверить живой сервер:

И одноразовый бутстрап первого админа (само-запирается через sentinel-флаг, второй вызов вернёт 410 Gone ):

Полный README со всеми таблицами эндпоинтов, каталогом из 116 типизированных аудит-событий и картой метрик OpenTelemetry — в репозитории . Issues и feedback — через GitHub Issues и Discussions.

redb.Identity — это четвёртый вариант, которого не хватало. Не ASP.NET -привязанный движок, где каждый эндпоинт — HTTP-middleware. Не отдельная IAM-коробка со своим рантаймом и деплоем. И не «напиши своё» в третий раз.

Три вещи, которых нет у других в такой связке:

Протокол ≠ транспорт. Каждый эндпоинт — direct-vm:// -маршрут. HTTP — первый фасад, но соседний модуль зовёт token без сети, zero-copy. Хочешь другой транспорт — положил .tpkg . Хочешь убрать HTTP — выкинул фасад, оставил Core.

Протокол ≠ транспорт. Каждый эндпоинт — direct-vm:// -маршрут. HTTP — первый фасад, но соседний модуль зовёт token без сети, zero-copy. Хочешь другой транспорт — положил .tpkg . Хочешь убрать HTTP — выкинул фасад, оставил Core.

Никаких миграций. Пользователь, приложение, скоуп — это C#-классы. Дописал поле — оно в проде со следующего мгновения. Кастомные claims запросимы нативно, без jsonb и ручных индексов.

Никаких миграций. Пользователь, приложение, скоуп — это C#-классы. Дописал поле — оно в проде со следующего мгновения. Кастомные claims запросимы нативно, без jsonb и ручных индексов.

Identity как часть проекта. Можно взять только движок, без единого фасада, и дёргать OAuth-эндпоинты вызовом метода изнутри своего процесса — вплоть до addon-проекта, который выписывает токен в одной транзакции со своими доменными данными. identity-сервер как библиотека, а не как сервис-сосед.

Identity как часть проекта. Можно взять только движок, без единого фасада, и дёргать OAuth-эндпоинты вызовом метода изнутри своего процесса — вплоть до addon-проекта, который выписывает токен в одной транзакции со своими доменными данными. identity-сервер как библиотека, а не как сервис-сосед.

Плюс: PostgreSQL / MSSQL / SQLite из одного кода (1767 тестов зелёные на всех трёх), номера RFC проставлены прямо в коде, DPoP / PAR / DCR / SCIM / MFA / WebAuthn / backchannel-logout, JWKS live-rotation без рестарта, password-hashing pipeline с Argon2id + upgrade-on-login, audit «куда угодно» доказанный 9 интеграционными тестами, logical-level backup + cross-provider миграция, кластер без внешней координации, host-agnostic-деплой от 30-строчного worker'а до Tsak-кластера, референсная админка на Blazor — и всё это Apache 2.0.

Если пробуете — пишите в комментариях, какой сценарий встраивания у вас: сосед-сервис, in-process-библиотека или полноценный сервер за HTTP. По конкретным темам — WebAuthn/passkeys как Route-процессор, DPoP шаг за шагом, SCIM-синхронизация с AD, MFA без god-class — будут отдельные разборы в серии.

Исходники и релиз: github.com/redbase-app/redb-identity . Про БД redb: redb.ru . Прошлые статьи — в профиле .

Исходники и релиз: github.com/redbase-app/redb-identity . Про БД redb: redb.ru . Прошлые статьи — в профиле .

If this was useful — a ⭐ on GitHub helps others find it.

← Cybersecurity