
Большая и сложная статья — уровень «сложный», ~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.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.
Забегая вперёд: чем я это подкреплю
Такую статью легко написать и трудно проверить. «Полноценный 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.Route — интеграционный движок в духе Apache Camel: маршруты
From(...)….To(...), 30+ коннекторов, паттерны интеграции (EIP), транзакции, телеметрия.redb.Tsak — рантайм, который берёт маршруты и превращает их в прод-сервис: дашборд, hot-reload модулей (
.tpkg), кластер с координатором.


redb.Identity — четвёртый слой поверх этих трёх. Он не владеет собственной схемой БД и не поднимает собственный HTTP-стек как обязательный. Он берёт движок маршрутов redb.Route, кладёт на него конвейер OpenIddict, а хранение отдаёт в redb. Что из этого получается — дальше.
Весь код в примерах — на английском, весь текст — по-русски. Всё лежит в репозитории, можно повторять.
Killer-фича №1: протокол ≠ транспорт
Каждый публичный эндпоинт Identity зарегистрирован на маршруте direct-vm://identity-*. direct-vm — это внутрипроцессный, кросс-контекстный, синхронный zero-copy транспорт redb.Route. Из этого бесплатно вытекают три следствия.
1. Другой модуль в том же воркере вызывает Identity вообще без сети
Представьте: у вас в одном Tsak-воркере крутится Identity и ваш бизнес-модуль. Бизнес-модулю нужен service-account токен, чтобы сходить в соседний API. В ASP.NET-мире это HTTP-запрос на самого себя через loopback. Здесь — обычный вызов внутри процесса:
// Внутри другого .tpkg-модуля, загруженного в тот же Tsak-воркер public class CheckoutRoutes : RouteBuilder { private IProducerTemplate _identity = null!; // request-reply в Identity, in-process protected override void Configure() { _identity = new ProducerTemplate(Context!); _identity.Start(); From("rabbitmq:checkout.orders") // Нужен service-account access token, чтобы дёрнуть downstream API? // Просто вызываем token-эндпоинт Identity прямо здесь — тот же процесс, без сокета. .Process(async (e, ct) => { var token = await _identity.RequestBody<TokenResponse>( IdentityEndpoints.Token, // "direct-vm://identity-token" new { grant_type = "client_credentials", client_id = "checkout-svc", /* ... */ }); e.In.SetHeader("Authorization", $"Bearer {token!.AccessToken}"); }) .To("http://pricing-api/quote"); } }
Что здесь происходит. По маршруту едет заказ из очереди — он и есть тело сообщения. Посередине мы делаем побочный вызов: спрашиваем у Identity service-account токен, забираем из ответа access_token и вешаем его заголовком на текущее сообщение. Заказ при этом не меняется. В конце заказ (уже с Authorization) уходит в чужой API.
Почему токен берут в .Process(...), а не через .To(IdentityEndpoints.Token) в DSL — вопрос закономерный, и ответ важный. Потому что .To() — это конвейер: ответ становится новым телом сообщения. Напишешь так:
From("rabbitmq:checkout.orders") .To(IdentityEndpoints.Token) // ← тело больше не заказ, а TokenResponse .To("http://pricing-api/quote"); // ← в pricing-api уедет ТОКЕН вместо заказа
— и заказ потерян: в чужой 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) |
|
дёрнуть pricing-api | HTTP | HTTP (неизбежно) |
Мы убрали первый — тот, которого вообще не должно было существовать. Ходить по HTTP к себе же в процесс, сериализуя JSON и жмя TLS-руку с самим собой, — это налог, который платят просто потому, что token оказался HTTP-middleware'ом.
Никакого HTTP-листенера. Никакого TLS-хендшейка. Никакого JSON по loopback. Exchange течёт прямо из вашего маршрута в процессор Identity и обратно — в том же потоке, с тем же экземпляром IExchange. Полная стоимость: вызов метода плюс та работа, которую и так делает конвейер OpenIddict.
Это ровно то, что не умеют ASP.NET-привязанные IS-серверы: у них token — это HTTP-middleware, и вызвать его «изнутри» без сети нельзя в принципе.
2. Фасады — чистые транспортные адаптеры, берёте нужные
HTTP — это первый фасад, но не единственный и не обязательный. Фасад — это тонкий мост, в котором нет бизнес-логики:
// HTTP-фасад — идёт в комплекте From(Http.From("0.0.0.0:5000")) .RedbController<TokenController>(); // POST /connect/token // ↓ Единственная работа контроллера: // exchange.To(IdentityEndpoints.Token) // direct-vm://identity-token // gRPC-фасад (в планах) — тот же паттерн From(Grpc.Server("0.0.0.0:5001/IdentityService")) .Filter(e => e.In.GetHeader("grpc.method") == "Token") .To(IdentityEndpoints.Token); // RabbitMQ RPC-фасад (в планах) From("rabbitmq:identity.rpc.token") .InOut() .To(IdentityEndpoints.Token);
Добавить транспорт — это положить ещё один .tpkg, который указывает на direct-vm://. Ядро при этом не трогается. Убрать транспорт — это rm facade.tpkg. Сломать в фасаде нечего — там нет логики.
Хотите заменить HTTP на RabbitMQ целиком? В ASP.NET-стеке это капитальный рефакторинг. Здесь — выкинь HTTP-фасад, оставь Core.
Ниже — как это выглядит на уровне процессоров. HTTP-фасад (redb.Identity.Http) объявляет HTTP-маршрут и форвардит его в direct-vm:
// HttpFacadeRouteBuilder.cs From($"http:POST:0.0.0.0:{port}/connect/token?inOut=true{ClientCorsParams()}") .RouteId("http-token") .Process(HttpIdentityProcessors.PropagateCorrelationId) .Process(ClientAuthHttpProcessors.ExtractClientCredentials) .To(IdentityEndpoints.Token) // ← forward в direct-vm .Process(HttpIdentityProcessors.SerializeJsonResponse);
То есть HTTP — это тонкий адаптер: распарсил запрос, форварднул в direct-vm://identity-token, забрал ответ, сериализовал. Вся бизнес-логика живёт в direct-vm-маршрутах.
3. Браузер нужен ровно там, где он нужен по стандарту
Единственное взаимодействие 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 + браузер | Работает на любом транспорте |
|---|---|---|
| — | ✅ |
| ✅ | — |
| — | ✅ |
| — | ✅ |
| — | ✅ |
| ✅ | — |
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#-класс с атрибутом. Вот как выглядит пользователь:
// src/redb.Identity.Core/Models/UserProps.cs [RedbScheme("identity.user")] public class UserProps { // Стандартные OIDC-claims профиля public string? GivenName { get; set; } public string? FamilyName { get; set; } public string? Picture { get; set; } public bool EmailVerified { get; set; } // Структурированный OIDC-адрес (§5.1.1) — вложенный redb-объект, не JSON-блоб public AddressClaim? Address { get; set; } // Произвольные claims арендатора — каждая пара становится своей строкой props, // её можно фильтровать и индексировать без ALTER TABLE. public Dictionary<string, string>? CustomClaims { get; set; } // Мульти-провайдерные federation-связки — нативные строки props, горячий // обратный lookup идёт по RedbObject.value_string = "{providerId}:{sub}". public Dictionary<string, ExternalIdentity>? ExternalIdentities { get; set; } public string? ScimExternalId { get; set; } // RFC 7643 §3.1 }
Нужно добавить пользователю поле — 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(), никаких файлов миграций.
RTTI на уровне БД — что это за модель хранения
Подавляющее большинство 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 уровня базы:
Понятие из языка | В REDB |
|---|---|
| строка в |
| строка в |
|
|
Generic constraint | C#-side proxy |
Это и есть RTTI на уровне БД: типизированная информация о структуре данных живёт в БД, а не в C#-классах. C#-классы (UserProps, ApplicationProps, …) — это просто type-safe view на схемы. Что это даёт identity-серверу:
Один query language на всё. Поиск пользователя, токена по reference_id, authorization по subject GUID — один IRedbService, один LINQ-провайдер, один транзакционный контекст:
await _redb.Query<UserProps>() .Where(u => u.EmailVerified) .WhereRedb(o => o.Key == coreUserId) .FirstOrDefaultAsync(); await _redb.Query<TokenProps>() .WhereRedb(o => o.ValueString == referenceId) .FirstOrDefaultAsync();
Все 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 — разделение физическое через idscheme.
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:// из своего кода:
// В своём приложении: нужен клиентский токен для внутреннего вызова — // без единого сетевого хопа, без поднятого HTTP-сервера Identity вообще. var producer = new ProducerTemplate(ctx); // ctx — ваш IRouteContext producer.Start(); var token = await producer.RequestBody<TokenResponse>( IdentityEndpoints.Token, // "direct-vm://identity-token" new { grant_type = "client_credentials", client_id = "internal-svc", client_secret = secret });
Это меняет модель встраивания. identity-сервер перестаёт быть «сервисом рядом» и становится библиотекой внутри вашего процесса, у которой есть полноценный OAuth 2.1 / OIDC под капотом — но общаетесь вы с ним вызовом метода, а не запросом по сети. Когда наступит момент выставить те же эндпоинты наружу для внешних клиентов — добавляете HTTP-фасад одним .tpkg, и те же самые процессоры начинают отвечать ещё и по HTTP. Ядро не меняется.
Ни Duende, ни Keycloak, ни Auth0 так не умеют: у первого эндпоинт — это всегда HTTP-middleware, у остальных это вообще внешний сервис с сетевым хопом.
Два контекста, один воркер


Архитектурно это выглядит так — два RouteContext внутри одного Tsak-воркера:
┌───────────────────────────────────────────────────────────────────────┐ │ Tsak worker │ │ │ │ ┌───────────────────────────────────────────────────────────────────┐ │ │ │ RouteContext "identity" ← redb.Identity.Core.Module.tpkg │ │ │ │ ───────────────────────────────────────────────────────────── │ │ │ │ • ~50 маршрутов direct-vm:// │ │ │ │ • конвейер OpenIddict (token, authorize, userinfo, ...) │ │ │ │ • redb-хранилища: Users, Apps, Scopes, Tokens, Sessions, Audit │ │ │ │ • DataProtection key-ring (RedbXmlRepository) │ │ │ │ • Signing keys (RSA 2048, зашифрованы at rest) │ │ │ │ • MFA: TOTP / SMS-Email OTP / WebAuthn / recovery codes │ │ │ │ • cleanup-таймеры (.Cluster(true) → leader-only в кластере) │ │ │ └───────────────────────────────────────────────────────────────────┘ │ │ ▲ direct-vm:// (синхронно, в процессе) │ │ │ │ │ ┌───────────────────────────────────────────────────────────────────┐ │ │ │ RouteContext "identity.http" ← redb.Identity.Http.tpkg │ │ │ │ ───────────────────────────────────────────────────────────── │ │ │ │ • Kestrel → redb.Route.Http → RedbController dispatcher │ │ │ │ • /connect/token, /authorize, /userinfo, /introspect, ... │ │ │ │ • /api/v1/identity/* (management), /me/* (self-service) │ │ │ │ • /scim/v2/Users, /Groups, /Bulk │ │ │ └───────────────────────────────────────────────────────────────────┘ │ │ │ │ ┌───────────────────────────────────────────────────────────────────┐ │ │ │ Ваши модули → зовут IdentityEndpoints.Token / .ManageUsers / ... │ │ │ │ через direct-vm:// напрямую, без HTTP │ │ │ └───────────────────────────────────────────────────────────────────┘ │ └───────────────────────────────────────────────────────────────────────┘ │ ▼ redb store (PostgreSQL / MSSQL / SQLite)
Отдельно стоит подчеркнуть изоляцию по project-reference. HTTP-фасад компилируется, не видя ни одного типа из redb.Identity.Core:
redb.Identity.Http ─project-ref→ redb.Identity.Contracts ✅ redb.Identity.Http ─project-ref→ redb.Identity.DataProtection ✅ redb.Identity.Http ─project-ref→ redb.Identity.Core ❌ ЗАПРЕЩЕНО
Фасад знает только про wire-DTO и константы эндпоинтов (redb.Identity.Contracts) и пару SPI-интерфейсов. Внутренние типы ядра не протекают через границу ABI. CI роняет сборку, если using redb.Identity.Core; вдруг появится где-то под redb.Identity.Http/. Именно это делает «.tpkg-историю» честной: два пакета собираются и версионируются независимо.
Анатомия запроса токена
Раз уж каждый эндпоинт — это маршрут, полезно посмотреть, из чего он собран. Токен-эндпоинт — не «метод контроллера», а декларативный пайплайн redb.Route:
// IdentityCoreRouteBuilder.tokenRoute (RouteId: identity-token) WithRedbTx(...) // атомарная граница записи (одна транзакция на выдачу) .Process(trustedProxy) // санитизация X-Forwarded-For ДО rate-limit .Process(perIpThrottle) // per-IP лимит (опционально) .OnException<InvalidOperationException>() // маппинг ошибок RFC 6749 → HTTP-статус .Throttle(clientId, ...) // per-client token bucket .Traced("identity.token-request") .Metered(..., new TokenEndpointProcessor(handler, timeProvider)) // ← конвейер OpenIddict // ↑ внутри — redb-хранилища: RedbTokenStore, RedbApplicationStore, ... .EndTraced() .WireTap("direct-vm://identity-events"); // аудит + мультикаст, fire-and-forget
Три вещи, которые тут видно и которые в ASP.NET-стеке обычно размазаны по middleware, фильтрам и атрибутам: транзакционная граница (WithRedbTx — либо токен выдан и записан целиком, либо откат), rate-limit до того, как запрос дойдёт до дорогой работы, и аудит как ответвление (WireTap) — оно не блокирует выдачу и не может её уронить. Тот же самый TokenEndpointProcessor отработает, пришёл вызов по HTTP, по gRPC или прямым direct-vm. Один пайплайн, один аудит-трейл, один набор тестов.
Хранилище: что это даёт против EF-Core-на-таблицах
Свойство | redb.Identity | Типичный EF-Core identity-сервер |
|---|---|---|
Движки хранения | PostgreSQL, MSSQL и SQLite из одного кода — меняется пакетом провайдера | один провайдер на сборку; смена = переписать EF-модель + миграции |
Эволюция схемы | нет миграций. Дописал поле в | сгенерируй миграцию, отревьюй SQL, прогони |
Кастомные claims / расширения арендатора |
|
|
Мульти-провайдерные federation-связки |
| join-таблица one-to-many, скаффолдинг на каждого провайдера |
Горячее/холодное разделение | горячие ключи в реляционном | либо всё в одной широкой таблице, либо 8 сателлитных |
Мультиарендная изоляция данных |
| один |
Провайдеры: ноль изменений кода между строчками
Движок | OSS-пакет | Pro-пакет |
|---|---|---|
PostgreSQL 13+ |
| |
Microsoft SQL Server 2019+ |
| |
SQLite 3.44+ |
|
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 за десять секунд, если мы соврём.
OAuth 2.0 / 2.1 core
RFC / спека | Что | Демо |
|---|---|---|
RFC 6749 OAuth 2.0 Framework | grant types, scopes, error responses |
|
RFC 6750 Bearer Token Usage |
|
|
RFC 6585 Additional HTTP Status Codes | 429 Too Many Requests + Retry-After |
|
RFC 7009 Token Revocation |
|
|
RFC 7521 Assertion Framework | ассершены для аутентификации клиента ( | покрыто через RFC 7523 |
RFC 7523 JWT Bearer Assertion (private_key_jwt) | client assertion на token / introspect / revoke / par / device |
|
RFC 7591 Dynamic Client Registration |
|
|
RFC 7592 DCR Management Protocol |
|
|
RFC 7636 PKCE | code_verifier / code_challenge / S256 ( |
|
RFC 7662 Token Introspection |
|
|
RFC 8252 OAuth 2.0 for Native Apps | public-клиенты с обязательным PKCE + loopback-редирект §7.3: порт не сравнивается (иначе |
|
RFC 8414 Authorization Server Metadata |
|
|
RFC 8628 Device Authorization Grant |
|
|
RFC 8693 Token Exchange | impersonation + delegation chain ( |
|
RFC 8725 JWT BCP | allow-list алгоритмов для DPoP-proof'ов и client-assertion'ов, |
|
RFC 9068 JWT Profile for Access Tokens | typed | заголовок токена видно в |
RFC 9126 Pushed Authorization Requests (PAR) |
| |
RFC 9207 OAuth 2.0 Authorization Response |
|
|
RFC 9449 DPoP | proof JWT validation, replay cache, |
|
OpenID Connect 1.0 семейство
Спецификация | Что | Демо |
|---|---|---|
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 |
|
OIDC Core §5.1 полный набор | все 14 claims, |
|
OIDC Core §5.4 доставка claims | scope-claims (profile/email/phone/address) отдаются из UserInfo, а не зашиваются в id_token — id_token остаётся токеном о факте аутентификации, а не контейнером с PII |
|
OIDC Core §5.5 | RP называет точные claims вместо того, чтобы тянуть весь |
|
OpenID Connect Discovery 1.0 |
|
|
OpenID Connect Dynamic Client Registration 1.0 | расширения RFC 7591 для OIDC client_metadata |
|
OpenID Connect RP-Initiated Logout 1.0 |
|
|
OpenID Connect Back-Channel Logout 1.0 |
|
|
Проверено официальным сьютом OpenID Foundation — не «мы всё сами протестили»
Каталог — это заявка. Доказательство — прогон того самого conformance-сьюта OpenID Foundation, которым OIDF и сертифицирует провайдеров. Не наши тесты, не «у нас всё работает» — официальный сьют против живого сервера. Большинство .NET-реализаций OAuth/OIDC его в глаза не видели; redb.Identity мы гоняем через него как настоящий OP.
Basic OP — 35 модулей, 0 провалов:
Результат | Модулей | Что это значит |
|---|---|---|
PASSED | 29 | прошли |
REVIEW | 4 | сьют требует скриншот от человека (login-форма, страница ошибки) — засчитано |
WARNING | 1 | два дополнительных claim в id_token — наше осознанное расширение, разбор ниже |
SKIPPED | 1 | request objects (RFC 9101) — мы их честно не объявляем в discovery, сьют корректно пропускает |
FAILED | 0 | — |
Профиль Config OP — тоже без провалов (нативный HTTPS, без reverse-proxy).

Сьют — штука безжалостная: он проверяет ровно те требования RFC, на которых самодельные серверы обычно и рассыпаются. И это не «прогнали для галочки» — он нашёл настоящие дефекты, и вот они, без прикрас:
ошибки авторизации уходят только на зарегистрированный клиентом
redirect_uri(RFC 6749 §4.1.2.1) — закрытый error-open-redirect, а не «редиректим по присланному URL»;повторно предъявленный authorization code →
400 invalid_grant, а не401 invalid_token(§5.2);Cache-Control: no-storeна token / introspect / revoke (§5.1);email_verified/phone_number_verified— JSON-boolean, а не строки (OIDC §5.1);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 вообще предполагал. Теперь их там нет;UserInfo, наоборот, отдавал лишнее — внутреннюю кухню токена:
oi_*-поля OpenIddict,jti/exp/iat/at_hash. Это свойства токена, а не пользователя; UserInfo (§5.3) обязан возвращать только claims пользователя;/connect/userinfoне принимал access-токен в теле POST-запроса (RFC 6750 §2.2);набор
profileбыл неполным — сьют сверяет UserInfo ровно со списком из §5.1 и ругается на каждый недостающий claim.
Заодно сьют выдал WARNING на oidcc-claims-essential — «name не найден в userinfo». Копнули: тест просит name через claims-параметр (§5.5), а он у нас не был реализован вовсе, и наш собственный discovery это честно признавал ("claims_parameter_supported": false). Реализовали.
Два WARNING, которые мы оставили сознательно
Самый первый модуль, oidcc-server, заканчивается с двумя предупреждениями:
WARNING EnsureIdTokenDoesNotContainNonRequestedClaims id_token contains non-requested claim 'oi_tkn_id' WARNING EnsureIdTokenDoesNotContainNonRequestedClaims id_token contains non-requested claim 'redb:user_id'
Прятать не будем — расскажем, почему они там и почему остаются.
Сначала важное: это WARNING, а не FAILURE. OIDC Core не запрещает дополнительные claims в id_token. Сьют предупреждает потому, что лишний claim может означать утечку данных о пользователе — и его собственный текст это признаёт: «…или что реализовано расширение, о котором сьют не знает». Наш случай — ровно второй.
Ни один из этих двух claims не является данными пользователя:
oi_tkn_id— идентификатор записи токена в сторе. Именно он позволяет отозвать id_token и обеспечивает работу back-channel logout. Выкинуть его — значит потерять отзыв id_token'ов. Это обмен реальной возможности на чистый список предупреждений, и мы такой обмен делать не стали.redb:user_id— наш приватный claim с namespace по конвенции RFC 7519 §4.3. Публичныйsub— это GUID (стабильный и корректный между инстансами), а в реляционной таблицеusersгорячий ключ — bigint. Этот claim позволяет клиенту раскодировать idtoken и сджойнить пользователя со своей таблицей по нашему внутреннему id, не ходя лишний раз на сервер.
И вот тут — то, что доказывает, что это не отмазка. В том же самом прогоне мы третий такой claim из id_token удалили. OpenIddict клал туда ещё и oi_au_id — свою внутреннюю ссылку на запись авторизации. Ей нечего делать в токене, который отдают клиенту: она не значит для него ничего и защитить её было нечем. Убрали (на access_token оставили — там она нужна для introspection).
То есть мы не махнули рукой на предупреждения. Мы разобрали каждое и оставили ровно те два, за которые можем ответить.
Вот это и есть граница между «набросал OAuth за выходные» и сервером, который проходит те же проверки, что промышленные IdP. Полная разбивка по модулям и локальный сетап — в OPENID_CERTIFICATION.md.
Про мамкину честность: значка OpenID Certified™ у нас нет — это товарный знак, он выдаётся OIDF по результатам формальной подачи. Мы говорим ровно то, что есть: сервер гоняется против официального сьюта OIDF, и результаты прогона лежат в репозитории.
JOSE / JWT
RFC | Что |
|---|---|
RFC 7515 JWS | подпись токенов |
RFC 7517 JWKS | публикация ключей |
RFC 7518 JWA | набор алгоритмов |
RFC 7519 JWT | сам формат токена |
RFC 7638 JWK Thumbprint | как |
RFC 7800 | proof-of-possession |
RFC 8176 | authentication methods reference |
SCIM 2.0
RFC | Что | Демо |
|---|---|---|
RFC 7643 SCIM Schema | User, Group, Meta |
|
RFC 7643 §4.3 Enterprise User extension |
|
|
RFC 7644 SCIM Protocol | CRUD + ETag concurrency (RFC 7232 → 412) + discovery. Filter — односоставный: |
|
Enterprise-расширение — это то, что корпоративный provisioning шлёт первым же запросом: Okta, Entra ID и Workday пушат department и manager на первой же синхронизации. Провайдер, который объявляет только core-схему, заставляет их выбросить эти данные.
SCIM-discovery endpoints (/scim/v2/ServiceProviderConfig, ResourceTypes, Schemas) реализованы unconditional unauthenticated — чтобы RP мог их пробовать ДО включения SCIM provisioning.
MFA / WebAuthn / OTP
Стандарт | Что | Демо |
|---|---|---|
RFC 6238 TOTP | Time-based OTP, атомарность и защита от replay по §5.2 (секрет — 160 бит по RFC 4226 §4) |
|
W3C WebAuthn Level 2 / FIDO2 | Passkey enrollment + assertion, | API готово, демо ждёт frontend-компаньона |
NIST SP 800-63B / OWASP ASVS 4.0.3 §2.1 | password policy (min 12, upper+lower+digit, history) |
|
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.ps1. Disable / replace метод — demo_mfa_disable_replace.ps1 (DELETE-unauth → DELETE → re-enrol с проверкой S1 ≠ S2).
Federation
Внешние OIDC IdP (Google, Microsoft, Keycloak) и отдельно GitHub OAuth2 — провайдеры федерации с реальным provision-on-first-login и link-on-replay поведением.
Сценарий | Демо |
|---|---|
Discovery |
|
Redirect surface (302 → IdP) |
|
Full callback round-trip против navikt/mock-oauth2-server (есть в dev docker-compose) |
|
Provision-on-first-login |
|
Link-on-replay (тот же |
|
GitHub OAuth2-only path (не OIDC discovery) — против github.com, GitHub Enterprise, Gitea, self-hosted мок |
|
Self-service link / unlink через |
|
Email-conflict resolution (отказ от silent takeover) |
|
LDAP / Active Directory — отдельный пакет redb.Identity.Ldap с bind-on-login и UserAccountControl-парсингом. Конфиг готов под OpenLDAP + AD-LDAPS пресеты.
Account self-service и Admin
Фича | Демо |
|---|---|
Self-registration + email verify |
|
Profile read/update, email change с dual-confirm |
|
Password change (positive + 13 негативов), forgot-password round-trip |
|
Sessions list / revoke own и admin (с dry-run) |
|
Account delete (self) — каскадный revoke |
|
OIDC client management (CRUD) |
|
Granular scope gate ( |
|
Group hierarchy + per-member role labels |
|
JWKS rotation с live-refresh |
|
Emergency-admin bootstrap endpoint |
|
Отдельно отмечу DPoP (RFC 9449) и private_key_jwt (RFC 7521/7523) — это редкие в .NET-экосистеме вещи, и здесь они не «в роадмапе», а реализованы и покрыты демо-пробами.

Production-grade фичи — то, что у других «потом»
Раздел про неочевидные, но крайне важные в продакшене вещи.
JWKS rotation с live-refresh — без рестарта процесса
Классическая проблема: вы выкатили OIDC-сервер, прошло 60 дней, надо ротировать signing key (кэш JWKS у RP'шников живёт до недели, плюс security-инциденты). У большинства имплементаций (включая базовый OpenIddict) ротация требует рестарта процесса, потому что OpenIddictServerOptions.SigningCredentials собирается один раз через IPostConfigureOptions<> и кэшируется.
У нас:
Админ дёргает
POST /signing-keys/rotate(требует scopeidentity:applications.manage).PropsSigningKeyStore.RotateAsyncсохраняет новый ключ в storage, помечает старый как demoted (но он остаётся в JWKS до retire — grace window для in-flight токенов).Сразу же инвалидируется
IOptionsMonitorCache<OpenIddictServerOptions>иIOptionsMonitorCache<OpenIddictValidationOptions>.Следующий читающий обработчик OIDC re-evaluate'ит весь Configure → PostConfigure chain —
PropsSigningKeyStoreOpenIddictPostConfigureперенакатываетSigningCredentialsиз актуального snapshot store'а.Новые токены сразу подписываются под just-rotated kid, in-flight токены валидируются под старым ключом до его retire.
Демо demo_jwks_rotation.ps1 ассертит: после 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 без перезапуска.
Cluster-safe примитивы — встроенные
Это identity-сервер, ему придётся жить в кластере. У нас встроены:
Optimistic concurrency на каждом
RedbObject<T>черезhash.UpdateAsyncберёт row-lock + сверяет hash + бросаетOpenIddictExceptions.ConcurrencyExceptionпри mismatch. ТестUpdateAsyncStaleHash_ThrowsConcurrencyExceptionгоняет именно этот сценарий с двумя параллельными write'ами.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 не нужен.Per-request cache invalidation через DI scope.
RedbApplicationStore._clientIdCacheживёт ровно одно HTTP-обращение и инвалидируется на CRUD — никаких stale data между requests.
Background deletion — БД как очередь
Удаление OIDC application с тысячами accumulated токенов и authorization'ов — дорогой cascade. Делать его в request thread — таймауты для пользователя. Делать через in-memory channel — теряется при crash воркера. У нас:
SoftDeleteAsyncсинхронно re-parent'ит объект под trash-scheme (scheme_id = -10). Объект мгновенно пропадает из всехQuery<>(). RP видит 204, операция «успешна».Background
BackgroundDeletionServiceопрашивает БД на pending trash containers каждые 5 секунд. Cluster-safe (TryClaimOrphanedTaskAsync). При crash воркера следующий poll-цикл подхватывает.Purge идёт батчами по 10 объектов с
Task.Delay(50ms)между — даёт live traffic вклиниваться.
Без in-memory state — crash посреди операции не теряет ничего, БД остаётся source of truth.
DataProtection key ring на storage-слое
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).
Rate limit с распределённым backend
/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.
Password hashing — Argon2id + BCrypt вместе, с upgrade-on-login и timing-attack mitigation
Раздел про детали, которые отделяют production-ready identity от учебного. Поддерживаем два алгоритма параллельно, без необходимости выбирать один на deployment level.
Argon2id — primary для новых deployment'ов. Параметры по умолчанию из OWASP Password Storage Cheat Sheet 2023:
new Argon2idPasswordHasher( memoryKib: 65536, // 64 MiB iterations: 3, parallelism: 4, saltBytes: 16, hashBytes: 32);
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 в свежий формат:
// LoginService.cs (упрощённо) if (_passwordHasher is not null && NeedsRehash(_passwordHasher, coreUser.Password)) { _ = Task.Run(async () => { using var rehashScope = scopeFactory.CreateScope(); var rehashRedb = rehashScope.ServiceProvider.GetRequiredService<IRedbService>(); var freshUser = await rehashRedb.UserProvider.GetUserByIdAsync(userId); await rehashRedb.UserProvider.SetPasswordAsync(freshUser, capturedPassword, ...); }); }
Через 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 одинаковый для всех трёх случаев:
// LoginService.AuthenticateLocal — fake-verify для consistency timing'а if (coreUser is null) { try { _ = BCrypt.Net.BCrypt.Verify(password ?? "", FakeBcryptHash); } catch { } _securityLogger.LogWarning("Login denied: user '{Username}' — invalid credentials or not found", username); return LoginResult.Failed("Invalid credentials."); }
Password policy — NIST SP 800-63B / OWASP ASVS 4.0.3 §2.1 (до хеширования):
Параметр | Дефолт | Конфигурируется |
|---|---|---|
| 12 | ✅ |
| true | ✅ |
| false | ✅ |
| 5 | ✅ |
| 90 дней | ✅ |
| false | ✅ |
HistoryCount хранится в PropsPasswordHistoryStore — SHA-256 hashed prior-password digests с peppered hash'ом в REDB (закрывает «пользователь меняет пароль и через час возвращается на старый»). Demo demo_password_change_negatives.ps1 гоняет все 13 негативных путей.
Аудит — «куда угодно», доказано интеграционными тестами
Аудит — не «прикрутим потом», а первый класс. 116 типизированных событий в 9 категориях с единым источником правды в IdentityAuditEventIds. Каждое событие ложится в реляционную таблицу identity_audit_log (user_id BIGINT, индексированный — прямой integer-seek, а не скан) и, если настроено, мультикастится во внешние приёмники. Плейнтекст-секреты в аудит не пишутся — ротация оставляет только маркер ClientSecretRotated.
Категория | Кол-во | Примеры |
|---|---|---|
| 4 |
|
| 14 |
|
| 22 |
|
| 9 |
|
| 11 |
|
| 9 |
|
| 9 |
|
Первые кандидаты в SIEM: LoginFailed, MfaVerifyFailed, MfaWebAuthnSignCounterAnomaly, DpopReplayDetected, FederationStateValidationFailed, ClientSecretRotated, AllSessionsRevoked.
Механика: WireTap на direct-vm
Каждое мутирующее действие (login, token issue, scope grant, MFA enroll, federation link, user delete, …) проходит через EventDispatchProcessor, который пишет типизированный IdentityEvent в exchange.Out.Body + headers. Дальше — стандартный redb.Route WireTap:
WithRedbTx(From(IdentityEndpoints.Token)) .Process(...) .WireTap(IdentityEndpoints.Events); // ← копия отлетает в audit pipeline
identity-events маршрут можно роутить в любое количество sinks одновременно — без переписывания identity-кода, это конфиг роутов.
Что подтверждено integration-тестами
«Куда угодно» — не маркетинг. 9 разных audit-таргетов имеют собственные интеграционные тесты в redb.Identity.Tests/Audit/, гоняются против реальных контейнерных broker'ов в dev compose stack:
Sink | Тест | Что проверяет |
|---|---|---|
SQL (Postgres / MS SQL audit table) |
| event с правильными колонками, parameter binding через redb.Route SQL connector |
PROPS storage (типизированное хранилище в той же REDB) |
|
|
Kafka |
| publish в topic, partition key из user_id / event_type, acks=all |
RabbitMQ |
| publish в exchange, routing key из event-type, durable queue с manual ack |
IBM MQ / WMQ |
| publish в MQQueue, MQMD с correlation-id, transactional context |
AMQP 1.0 |
| publish в standard AMQP node, properties + application-properties + body |
MQTT |
| publish в topic с QoS 1+, retained flag (audit-stream для IoT-gateway) |
Elasticsearch |
| bulk indexing в |
Redis |
| 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 корректно сериализуются и долетают. Не «архитектурный слайд», а зелёный билд против реальной инфраструктуры.
Real-world fan-out
From(IdentityEndpoints.Events) .Marshal(...) .Multicast( To("postgres://?table=identity_audit"), // durable retention To("kafka://broker:9092/identity-events?acks=all"), // real-time SIEM stream To("elasticsearch://logs/identity-events?bulk=true"), // Kibana ops dashboard To("wmq://QM.PROD?queue=COMPLIANCE.IDENTITY.AUDIT"), // банковский compliance core To("file:///var/log/identity/audit-${date:yyyy-MM-dd}.jsonl?append=true") // locked-down fallback );
Один 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-пайплайн:
builder.Services.AddOpenTelemetry() .WithMetrics(m => m.AddMeter("RedbIdentity"));
Внутри — счётчики логинов/отказов (тег 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.
Foreign-key-safe order — типы → роли → пользователи → user_roles → списки → list_items → схемы → структуры → объекты → permissions → values. Single-pass restore.
Filter by scheme subset —
--schemes UserProps,ApplicationProps. Восстановить тоже можно частично.Counts по типам — заголовок знает, сколько записей каждого типа было экспортировано, footer ассертит соответствие.
Multi-provider —
IDataProvider(Postgres / MSSQL / SQLite). Один.redbпереносим между движками — типизированная семантика сохраняется поверх dialect-разницы.dryRun — посчитать, что было бы выгружено, без записи на диск.
Production-пример — daily cron-бэкап через redb.Route
Реальное использование из боевого проекта. Файл TsumBackupRouteBuilder.cs:
public class TsumBackupRouteBuilder : RouteBuilder { private ILogger? Logger => Context?.GetService<ILogger>(); protected override void Configure() { var backupConfig = Context?.GetProperty<IDictionary<string, object?>>("Backup"); var directory = backupConfig?.TryGetValue("Directory", out var dir) == true ? dir?.ToString() ?? "backups" : "backups"; var retentionDays = backupConfig?.TryGetValue("RetentionDays", out var ret) == true && int.TryParse(ret?.ToString(), out var rd) ? rd : 7; Context!.SetProperty("_backup.directory", directory); Context.SetProperty("_backup.retentionDays", retentionDays); From("cron://tsum-backup?schedule=0 0 3 * * ?") // ← Quartz cron, каждый день в 03:00 .RouteId("tsum-backup-cron") .ProcessWithRedb(RunBackupAsync); // ← named-scope IRedbService инжектится } private async Task RunBackupAsync(IRedbService redb, IExchange exchange, CancellationToken ct) { var pgConn = redb.Configuration.ConnectionString; var directory = Context!.GetProperty<string>("_backup.directory")!; var retentionDays = Context.GetProperty<int>("_backup.retentionDays"); Directory.CreateDirectory(directory); var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd_HHmmss"); var filePath = Path.Combine(directory, $"tsum_backup_{timestamp}.redb"); var provider = ProviderFactory.Create("postgres"); await provider.OpenAsync(pgConn, ct); var exportService = new ExportService(provider, verbose: false, batchSize: 10000); await exportService.ExportAsync(filePath, schemeIds: null, compress: true, dryRun: false, ct); await provider.DisposeAsync(); RotateBackups(directory, retentionDays); } private void RotateBackups(string directory, int retentionDays) { var cutoff = DateTime.UtcNow.AddDays(-retentionDays); foreach (var file in Directory.GetFiles(directory, "tsum_backup_*.redb") .Select(f => new FileInfo(f)) .Where(f => f.CreationTimeUtc < cutoff)) { file.Delete(); } } }
Никаких отдельных 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.
Restore и cross-provider миграция
var importService = new ImportService(provider, verbose: false, batchSize: 10000); await importService.ImportAsync("tsum_backup_2026-06-20_030000.redb", schemeIds: null, // null = весь файл; можно subset truncateBefore: false, // или true для clean restore ct);
Один из killer-сценариев — мигрировать identity-стек между движками без переписывания кода (за три команды CLI):
# 1. Экспорт со старой БД redb export -p postgres -c "Host=src;Database=redb;..." -o data.redb --compress -v # 2. Bootstrap схемы на новой БД redb init -p mssql -c "Server=dst;Database=redb;..." -v # 3. Импорт redb import -p mssql -c "Server=dst;Database=redb;..." -i data.redb --clean -v
.redb типизированный и кросс-провайдерный — dialect-различия инкапсулированы в IDataProvider. Identity после миграции видит ровно тот же набор данных: те же OpenIddictApplication-id, те же sub пользователей, те же signing keys. Это уровень портативности, которого нет ни у IdentityServer (привязан к EF-провайдеру и migrations), ни у Keycloak (Java SPI ↔ Postgres/MariaDB), ни у Auth0 (нет on-prem вообще).
redb.CLI — global .NET tool для всех BAU-операций
Тот же pipeline доступен из терминала через redb.CLI (на nuget.org):
dotnet tool install --global redb.CLI
Четыре команды покрывают весь identity-операционный цикл:
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 export— backup в.redb(--compress,--schemes 100,200,300,--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:
var builder = Host.CreateApplicationBuilder(args); builder.Services.AddRedbCore(builder.Configuration); builder.Services.AddRedbRoute(); builder.Services.AddRedbIdentity(builder.Configuration); var host = builder.Build(); var ctx = host.Services.GetRequiredService<IRouteContext>(); ctx.AddRoutes(new IdentityCoreRouteBuilder(/*...*/)); ctx.AddRoutes(new HttpFacadeRouteBuilder(/*...*/)); await ctx.Start(); await host.RunAsync();
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 одного | Обновили |
Multiple named contexts в одном процессе | Identity рядом с |
Blazor Server дашборд + REST API | Operator видит все identity-routes, метрики per-route, ring-buffer logs, может остановить один маршрут (например |
CLI ( | CI/CD-пайплайны без custom kubectl-обвязки |
REST-эндпоинты ( | Свой ops-портал через типизированный |
Cluster mode с leader election + redistribution контекстов | Реплика падает — Tsak перенаправляет её контексты на здоровые ноды |
Quartz | Token cleanup, session expiry, JWKS auto-rotation, audit retention — на стандартном cron'е |
Watchdog детектит hung routes | Federation callback застрял на 30s — Tsak перезапустит, без manual ops alert'ов в 03:00 |
Prometheus | 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 (например |
5-layer config hot-reload | Изменили |
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. Спектр деплоя:
┌── Опция А ── Самописный Program.cs на 30 строк (junior-friendly) ─────────┐ │ Worker Service + AddRedbIdentity + AddRoutes. Деплой systemd / Docker. │ ├── Опция Б ── Monolith ASP.NET Core с identity внутри ─────────────────────┤ │ Один процесс, identity рядом с вашими controller'ами. │ ├── Опция В ── Sidecar в каждом микросервисе ───────────────────────────────┤ │ In-process embedding — direct-vm token issue без сетевого хопа. │ ├── Опция Г ── Tsak с одним identity-модулем ───────────────────────────────┤ │ Hot-reload, дашборд, REST/CLI. Но без cluster mode и десятков модулей. │ ├── Опция Д ── Tsak full enterprise: identity + ваши 20 других модулей ─────┤ │ Cluster + leader election + Prometheus + Jaeger + Grafana + k8s deploy. │ │ Свои custom Tsak-расширения (IModuleProvider, watchdog, ...). │ └────────────────────────────────────────────────────────────────────────────┘ ⬇️ Identity-сервер один и тот же на всех уровнях ⬇️
Расширение рядом с identity — своя схема и свои маршруты
Identity-сервер не «закрытая коробка» — он живёт в той же REDB и в том же IRouteContext, что и любой ваш бизнес-маршрут. Это значит: объявляете свой типизированный prop-класс, регистрируете схему рядом с identity-схемами (без ALTER TABLE), получаете полноценный CRUD + LINQ через тот же IRedbService, и WireTap'аете свои маршруты на identity-events:
// Ваш модуль рядом с identity — никаких разрешений просить не надо public sealed class OrgEnrollmentProps { public long UserId { get; set; } // FK на identity-юзера через _users._id public string OrganisationCode { get; set; } = ""; public DateTimeOffset EnrolledAt { get; set; } public string Role { get; set; } = ""; public Dictionary<string, string>? Tags { get; set; } } services.AddRedbScheme<OrgEnrollmentProps>(); var enrollments = await redb.Query<OrgEnrollmentProps>() .Where(e => e.OrganisationCode == "ACME") .WhereRedb(o => o.Key == userId) .ToListAsync(); From(IdentityEndpoints.Events) .Filter(e => e.GetHeader<string>("event-type") == "UserCreated") .Process((e, ct) => /* ваш onboarding flow */) .To("rabbitmq://org-enrollment-pipeline");
Никаких extension points, SPI, plugin manifest'ов и rebuild'а identity-сервера — это обычный C#-код в обычной сборке, который сосуществует с identity-стеком в одном процессе и одной БД. Хотите — добавляете в Tsak как отдельный .tpkg (деплоится независимо). Хотите — компилируете вместе с identity в один worker.
Addon вызывает identity-сервер изнутри через direct-vm — без HTTP, в одной транзакции
Это сильнее, чем просто 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-сервер, всё в одной транзакции:
public sealed class OrgEnrollmentRoutes : RouteBuilder { protected override void Configure() { WithRedbTx(From("direct-vm://org-enrollment-create-with-token")) .ProcessWithRedb(async (redb, e, ct) => { // 1. Создаём enrollment-запись в СВОЕЙ схеме var enrollment = new RedbObject<OrgEnrollmentProps>(new OrgEnrollmentProps { UserId = e.GetHeader<long>("user_id"), OrganisationCode = "ACME", EnrolledAt = DateTimeOffset.UtcNow, Role = "member", }); await redb.SaveAsync(enrollment, ct); }) // 2. ↓↓↓ ВЫЗЫВАЕМ IDENTITY-СЕРВЕР через direct-vm — БЕЗ HTTP ↓↓↓ .Process((e, ct) => { e.In.Headers["grant_type"] = "client_credentials"; e.In.Headers["client_id"] = "org-onboarding-bot"; e.In.Headers["scope"] = "openid org:onboarding"; return Task.CompletedTask; }) .To(IdentityEndpoints.Token) // ← "direct-vm://identity-token" — identity в том же процессе // ↑↑↑ access_token уже в e.Out.Body, никакого HTTP-роунд-трипа не было ↑↑↑ .Process(async (e, ct) => { var tokenResponse = e.Out!.Body as Dictionary<string, object?>; var accessToken = tokenResponse!["access_token"]?.ToString(); await _emailSender.SendOnboardingAsync(/* userId */, accessToken!, ct); }); } }
Что здесь произошло: один маршрут делает 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) |
|
gRPC-фасад |
|
MQTT для IoT (устройства не умеют HTTP/2) |
|
Внутренний enterprise message bus (WMQ как единственный transport в банковском контуре) |
|
Свой шифровальный wrapper (token-request в custom crypto-envelope) |
|
Любой коннектор 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, версией протокола, нестандартными маркерами — на отдельном порту и в отдельной сети:
// Управляющий фасад на проприетарном протоколе — отдельный порт, отдельная сеть From("custom-tcp://0.0.0.0:7891") .RouteId("identity-admin-proprietary-facade") .Process(ValidateMagicBytes) // ← клиент без знания формата сюда не зайдёт .Process(ParseCustomFraming) // ← бинарный формат с CRC + version + opcode .Process(VerifyProprietaryAuthHmac)// ← свой HMAC, не RFC .Choice() .When(e => e.GetHeader<int>("opcode") == 0x42).To(IdentityEndpoints.SigningKeyRotate) .When(e => e.GetHeader<int>("opcode") == 0x43).To(IdentityEndpoints.UserForceRevoke) .When(e => e.GetHeader<int>("opcode") == 0x44).To(IdentityEndpoints.BootstrapAdmin) .EndChoice() .Process(SerializeCustomResponse);
Это не замена крипто-защите — 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 — для них ничего не меняется.
Точки расширения — везде, не только identity
Свобода расширения — системное свойство всей экосистемы:
Слой | Точки расширения |
|---|---|
REDB storage |
|
redb.Route | свой коннектор ( |
redb.Identity |
|
redb.Tsak |
|
redb.Route.Controllers |
|
Везде один паттерн: публичный интерфейс с понятным контрактом + DI-registration + ваша реализация. Зарегистрировали свою реализацию — identity подхватил, не заметив подмены. Никаких SPI-manifest'ов и vendor-lock-in моментов.
Комбинаторика с LLM-коннектором
Поставьте рядом с 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, а не отдельный продукт за доп-лицензию:
Сценарий | Pipeline |
|---|---|
AI-аномалия-детектор на login-паттерны |
|
Real-time fraud scoring на token-issue |
|
Conversational admin через Telegram/Slack |
|
LLM-классификация audit-events |
|
Auto-compliance reports на квартал |
|
LLM-валидация redirect_uri при DCR | новый OIDC client → |
У вас уже есть 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-to-service auth — без HTTP-роунд-трипа
Классика: 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_credentialsissue идёт черезdirect-vm://identity-token— zero network hop.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 видит
actclaim chain (RFC 8693 §4.1) с историей делегаций. Демоdemo_token_exchange.ps1.
Кластерная стабильность — для many-thousand сессий
Identity спроектирован под N-реплик, где лидерство ротируется без внешней координации:
Забота | Механизм |
|---|---|
Stateless replicas | все handler'ы Scoped, no static state — любая реплика обрабатывает любой запрос |
DataProtection key-ring между репликами |
|
OAuth signing keys между репликами | redb-хранилище RSA 2048 PEM, зашифрованы DataProtection'ом, бутстрап под распределённым локом (первый ключ генерит одна реплика) |
Federation state nonce / rate-limit counters |
|
Schema init | cluster-wide lock через |
Background trash purge |
|
Signing key rotation |
|
Backchannel logout между репликами |
|
Никакого ZooKeeper, никакого отдельного стораджа для ключей — всё это redb-объекты. В трёх-нодном кластере под нагрузкой ~5K /token RPS identity-стек работает с p99 в районе 50–80ms (Pro-path с PVT-индексами по value_string для client_id lookup).
Data sovereignty — identity не уезжает за периметр
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 + ваша сеть.
Multi-tenant через schemes
Классический OIDC multi-tenant — /{tenant}/connect/token, отдельные DB-schemas, отдельные key rings — требует tenant routing в каждом handler'е, tenant-aware migrations, per-tenant key rotation. У нас изоляция возможна на уровне схем REDB: разделение физическое через idscheme, 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 и чёрно-ящичный регресс-нет.
cd redb.Identity/demos pwsh -File .\run_all.ps1 # все demo_*.ps1 в каноническом порядке pwsh -File .\run_all.ps1 -Only mfa # только про MFA pwsh -File .\run_all.ps1 -StopOnFail # стоп на первом упавшем
run_all.ps1 запускает каждую пробу в своём дочернем процессе pwsh (жёсткое падение одной не рушит остальные), стримит вывод, пишет транскрипт в demos/_logs/ и печатает итоговую таблицу pass/fail. Карта проб по категориям:

Grant types demo_client_credentials / demo_authcode_pkce / demo_refresh_rotation demo_device_code (+_ci) / demo_password_ropc / demo_token_exchange Endpoints demo_discovery_jwks / demo_discovery_shape / demo_introspect_revoke demo_userinfo / demo_jwks_rotation Authorize surface demo_auth_extras (RFC 9207 iss, form_post) / demo_prompt_max_age demo_acr_values / demo_claim_probes PAR / DPoP demo_par / demo_par_per_client / demo_dpop Logout demo_logout_endsession / demo_backchannel_logout DCR demo_dcr_lifecycle / demo_private_key_jwt SCIM 2.0 demo_scim / demo_scim_bulk / demo_scim_etag MFA demo_mfa_totp / demo_mfa_recovery_codes / demo_mfa_disable_replace demo_password_change_negatives Self-service demo_account_register_verify / demo_me_profile / demo_me_email_change demo_me_sessions / demo_me_delete / demo_password_reset Federation demo_federation / demo_federation_e2e / demo_federation_github demo_federation_link_unlink Admin demo_admin_scopes / demo_groups_roles_claims / demo_sessions_admin demo_throttle_rfc6585 / demo_jwt

Единственная проба, требующая ручного интерактива, — device-code consent (для CI есть неинтерактивный demo_device_code_ci.ps1).
Кластер: реплики без лишней инфраструктуры
Identity спроектирован под N-реплик, где лидерство ротируется без внешней координации (детально — в разделе про enterprise scale). Коротко:
Забота | Механизм |
|---|---|
DataProtection key-ring между репликами |
|
OAuth signing keys между репликами | redb-хранилище RSA 2048 PEM, зашифрованы DataProtection'ом, бутстрап под распределённым локом |
Cleanup-таймеры (токены/сессии/OTP/челленджи/revoked SID) |
|
Backchannel logout между репликами |
|
Никакого 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;все вызовы Identity идут через типизированный
IIdentityClient— никогда не через сыройHttpClient;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 снаружи:
services.AddIdentityClient(o => { o.BaseUrl = "https://identity.local"; o.AccessTokenProvider = new ClientCredentialsAccessTokenProvider( clientId: "my-svc", clientSecret: cfg["IdentitySvcSecret"]!, scopes: ["identity:manage"]); }); public class UserSync(IIdentityClient identity) { public async Task RunAsync(CancellationToken ct) { var page = await identity.Users.ListAsync(new UsersListRequest { Limit = 100 }, ct); foreach (var u in page.Items) await PersistAsync(u, ct); // backchannel-отзывы — тянем дельты с последнего курсора var since = await identity.RevokedSids.GetSinceAsync(_cursor, ct); foreach (var e in since.Entries) _cache.Apply(e); _cursor = since.NextCursor; } }
Каждая HTTP-ошибка нормализуется в RFC 7807 ProblemDetails и всплывает как ApiException.
Немного сравнений (честно, без агрессии)
ASP.NET-привязанный IS (Duende, сэмплы OpenIddict) | redb.Identity | Standalone IAM (Keycloak / Auth0) | |
|---|---|---|---|
Вызвать | Loopback HTTP |
| Сетевой хоп |
Добавить транспорт (gRPC, MQ, SignalR) | Переписать эндпоинты как gRPC-сервисы / консьюмеры | Положить фасад | Вендорский адаптер (если есть) |
Заменить HTTP на RabbitMQ целиком | Капитальный рефакторинг | Выкинуть HTTP-фасад. Оставить Core. | Невозможно |
Хранение | Кастомная EF-схема + миграции | redb | Вендорская схема, вендорские миграции |
Кастомные claims |
|
| Вендорская модель атрибутов |
Шаринг ключей на мульти-реплику | DIY DataProtection + JWKS | Из коробки: redb key-ring + signing key store | Из коробки (по-вендорски) |
Встроить в свой воркер как библиотеку | Нет | Да — один-два | Нет |
Провайдеры БД | Один на сборку | 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-провайдер, без единой настройки:
docker run -d -p 5002:5002 ghcr.io/redbase-app/redb-identity-backend:latest curl -k https://localhost:5002/.well-known/openid-configuration
Образ несёт 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.ps1 / start-full.sh.
А для встраивания в свой проект — пакеты уже на nuget.org. Берёте то, что нужно — провайдер выбирает хост:
# Ядро OIDC / OAuth 2.1 (OpenIddict на redb.Route) + провайдер хранения dotnet add package redb.Identity.Core dotnet add package redb.Postgres.Pro # либо redb.MSSql.Pro / redb.SQLite.Pro # HTTP-фасад (OIDC + management + SCIM) dotnet add package redb.Identity.Http # Типизированный клиентский SDK (IIdentityClient) — для BFF / сервисов dotnet add package redb.Identity.Client
Или собрать .tpkg-модули из исходников и уронить в Tsak-воркер:
cd redb.Identity .\scripts\pack-tpkg.ps1 # → redb.Identity.Core.Module.tpkg + redb.Identity.Http.tpkg
Полный dev-setup с реальной инфраструктурой:
# 0. (опционально) redb.CLI глобально — для работы с БД из терминала dotnet tool install --global redb.CLI # 1. Dev compose (Postgres + GreenMail + mock IdP + Redis + LDAP) docker compose -f redb.Identity/deploy/observability/docker-compose.yml up -d # 1.1. Инициализировать REDB-схему redb init -p postgres -c "Host=localhost;Database=redb;Username=postgres;Password=postgres" -v # 2. Запустить worker с tpkg-модулями cd redb.Tsak/src/redb.Tsak.Worker dotnet run # 3. В отдельном окне — прогнать демо cd redb.Identity/demos pwsh demo_discovery_jwks.ps1 # OIDC discovery + JWKS + ручная верификация pwsh demo_authcode_pkce.ps1 # full PKCE round-trip pwsh demo_jwks_rotation.ps1 # ротация подписных ключей в рантайме pwsh run_all.ps1 # все пробы
Проверить живой сервер:
curl http://localhost:5000/.well-known/openid-configuration | jq curl http://localhost:5000/.well-known/jwks | jq
И одноразовый бутстрап первого админа (само-запирается через sentinel-флаг, второй вызов вернёт 410 Gone):
curl -X POST http://localhost:5000/internal/bootstrap-admin \ -H "Content-Type: application/json" \ -d '{ "username": "admin", "password": "...", "email": "admin@local" }'
Полный README со всеми таблицами эндпоинтов, каталогом из 116 типизированных аудит-событий и картой метрик OpenTelemetry — в репозитории. Issues и feedback — через GitHub Issues и Discussions.
Итог
redb.Identity — это четвёртый вариант, которого не хватало. Не ASP.NET-привязанный движок, где каждый эндпоинт — HTTP-middleware. Не отдельная IAM-коробка со своим рантаймом и деплоем. И не «напиши своё» в третий раз.
Три вещи, которых нет у других в такой связке:
Протокол ≠ транспорт. Каждый эндпоинт —
direct-vm://-маршрут. HTTP — первый фасад, но соседний модуль зовётtokenбез сети, zero-copy. Хочешь другой транспорт — положил.tpkg. Хочешь убрать HTTP — выкинул фасад, оставил Core.Никаких миграций. Пользователь, приложение, скоуп — это C#-классы. Дописал поле — оно в проде со следующего мгновения. Кастомные claims запросимы нативно, без
jsonbи ручных индексов.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. Прошлые статьи — в профиле.
If this was useful — a ⭐ on GitHub helps others find it.
