redb.Identity
redb.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
redb.Identity

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. Это продолжение серии, свежие статьи — сверху:

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


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

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

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

redb.Identity
redb.Identity

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.tsak
redb.tsak
redb.tsak
redb.tsak

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)

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

дёрнуть 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). Всё остальное — tokenrefresh_tokenintrospectrevokeuserinfo, запрос device_code, management API, MFA verify, SCIM — транспортно-нейтрально и отлично работает через gRPC, AMQP, MQ или прямой вызов.

Flow / эндпоинт

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

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

client_credentials (M2M)

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

authorization_code — обмен code → token

refresh_tokenrevokeintrospectuserinfo

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#-класс с атрибутом. Вот как выглядит пользователь:

// 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
}

Нужно добавить пользователю поле — LoyaltyTierManagerSubjectDepartmentCode? Вы буквально дописываете его в класс. Никакой миграции, никакого звонка 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.applicationidentity.scopeidentity.tokenidentity.sessionidentity.useridentity.groupidentity.mfaidentity.webauthn_*identity.federation_provideridentity.claim_mapperidentity.dpop_consumed_jtiidentity.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

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#-классы (UserPropsApplicationProps, …) — это просто 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-сторы (RedbApplicationStoreRedbAuthorizationStoreRedbTokenStoreRedbScopeStore) плюс наши (PropsSigningKeyStorePropsServerSideOtpStorePropsWebAuthnChallengeStorePropsPasswordHistoryStore) живут поверх одного интерфейса.

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, у остальных это вообще внешний сервис с сетевым хопом.


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

redb.tsak
redb.tsak
redb.tsak
redb.tsak

Архитектурно это выглядит так — два 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-модель + миграции

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

нет миграций. Дописал поле в *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-приложения

Провайдеры: ноль изменений кода между строчками

Движок

OSS-пакет

Pro-пакет

PostgreSQL 13+

redb.Postgres

redb.Postgres.Pro

Microsoft SQL Server 2019+

redb.MSSql

redb.MSSql.Pro

SQLite 3.44+

redb.SQLite

redb.SQLite.Pro

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

demo_client_credentials.ps1demo_authcode_pkce.ps1demo_refresh_rotation.ps1demo_password_ropc.ps1

RFC 6750 Bearer Token Usage

Authorization: BearerWWW-Authenticate: Bearer challenge

demo_userinfo.ps1 — отдельно ассертит 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.ps1

RFC 7521 Assertion Framework

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

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

RFC 7523 JWT Bearer Assertion (private_key_jwt)

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

demo_private_key_jwt.ps1 — DCR с inline JWKS + token + introspect + tamper-negative

RFC 7591 Dynamic Client Registration

POST /connect/register

demo_dcr_lifecycle.ps1

RFC 7592 DCR Management Protocol

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

demo_dcr_lifecycle.ps1 (тот же файл)

RFC 7636 PKCE

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

demo_authcode_pkce.ps1

RFC 7662 Token Introspection

/connect/introspect

demo_introspect_revoke.ps1

RFC 8252 OAuth 2.0 for Native Apps

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

demo_loopback_redirect.ps1 — 14 ассертов, почти все негативные

RFC 8414 Authorization Server Metadata

/.well-known/oauth-authorization-server

demo_discovery_jwks.ps1

RFC 8628 Device Authorization Grant

/connect/deviceauthorization, polling 5s

demo_device_code.ps1 + non-interactive demo_device_code_ci.ps1 для CI

RFC 8693 Token Exchange

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

demo_token_exchange.ps1 — positive + negative

RFC 8725 JWT BCP

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

demo_dpop.ps1 (DPoP-proof), discovery (*_auth_signing_alg_values_supported)

RFC 9068 JWT Profile for Access Tokens

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

заголовок токена видно в demo_jwt.ps1

RFC 9126 Pushed Authorization Requests (PAR)

/connect/parrequire_pushed_authorization_requests per-client

demo_par.ps1 + demo_par_per_client.ps1

RFC 9207 OAuth 2.0 Authorization Response iss

iss в auth-response

demo_auth_extras.ps1

RFC 9449 DPoP

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

demo_dpop.ps1

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

demo_discovery_jwks.ps1demo_claim_probes.ps1demo_prompt_max_age.ps1demo_acr_values.ps1

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

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

demo_claim_probes.ps1

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

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

demo_claim_probes.ps1 — ассертит отсутствие PII в id_token

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

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

demo_claims_parameter.ps1 — 14 ассертов, с негативами

OpenID Connect Discovery 1.0

/.well-known/openid-configuration + JWKS

demo_discovery_jwks.ps1 + ручная RS256-верификация

OpenID Connect Dynamic Client Registration 1.0

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

demo_dcr_lifecycle.ps1

OpenID Connect RP-Initiated Logout 1.0

/connect/logout + post_logout_redirect_uri

demo_logout_endsession.ps1

OpenID Connect Back-Channel Logout 1.0

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

demo_backchannel_logout.ps1

Проверено официальным сьютом 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).

redb.Identity
redb.Identity

Сьют — штука безжалостная: он проверяет ровно те требования 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

как jkt для DPoP

RFC 7800 cnf / PoP

proof-of-possession

RFC 8176 amr

authentication methods reference

SCIM 2.0

RFC

Что

Демо

RFC 7643 SCIM Schema

User, Group, Meta

demo_scim.ps1

RFC 7643 §4.3 Enterprise User extension

departmentmanageremployeeNumbercostCenterorganizationdivisionmanager — комплексный атрибут; $ref и displayName выводятся, а не хранятся (иначе протухнут при переименовании руководителя)

demo_scim_enterprise.ps1 — 24 ассерта

RFC 7644 SCIM Protocol

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

demo_scim.ps1 (CRUD) + demo_scim_bulk.ps1 (bulk) + demo_scim_etag.ps1 (concurrency)

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

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

MFA / WebAuthn / OTP

Стандарт

Что

Демо

RFC 6238 TOTP

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

demo_mfa_totp.ps1

W3C WebAuthn Level 2 / FIDO2

Passkey enrollment + assertion, attestation=none/direct/indirect/enterpriseuserVerification=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.ps1 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.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 federation_providers API

demo_federation.ps1

Redirect surface (302 → IdP)

demo_federation.ps1

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

demo_federation_e2e.ps1

Provision-on-first-login

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.ps1

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

demo_federation_link_unlink.ps1

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

demo_federation_link_unlink.ps1 step 3

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

Account self-service и Admin

Фича

Демо

Self-registration + email verify

demo_account_register_verify.ps1

Profile read/update, email change с dual-confirm

demo_me_profile.ps1demo_me_email_change.ps1

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

demo_password_change_negatives.ps1demo_password_reset.ps1

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

demo_me_sessions.ps1demo_sessions_admin.ps1

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

demo_me_delete.ps1

OIDC client management (CRUD)

demo_dcr_lifecycle.ps1

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

demo_admin_scopes.ps1 17/17

Group hierarchy + per-member role labels

demo_groups_roles_claims.ps1 16/16

JWKS rotation с live-refresh

demo_jwks_rotation.ps1 13/13

Emergency-admin bootstrap endpoint

BootstrapAdminEndpointTests 9/9

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

redb.Identity
redb.Identity

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

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

JWKS rotation с live-refresh — без рестарта процесса

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

У нас:

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

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

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

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

  5. Новые токены сразу подписываются под 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> через hashUpdateAsync берёт 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 воркера. У нас:

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

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

  3. 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 (до хеширования):

Параметр

Дефолт

Конфигурируется

MinLength

12

RequireDigit / RequireUppercase / RequireLowercase

true

RequireSpecial

false

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

5

MaxAge (force rotation)

90 дней

BreachCheckEnabled (haveibeenpwned probe)

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.

Категория

Кол-во

Примеры

authentication

4

UserLoggedInLoginFailedPasswordChanged

authorization

14

TokenIssued/Revoked/IntrospectedConsentGrantedDpopReplayDetected

admin

22

Client*Scope*User*Group*ClaimMapper*

federation

9

FederatedUserLoggedInFederationStateValidationFailedFederatedEmailConflict

mfa

11

MfaEnrolledMfaVerifyFailedMfaWebAuthnSignCounterAnomaly

scim

9

ScimUserCreated/Replaced/PatchedScimBulkProcessed

system

9

SessionRevokedSidRevokedTokensRevokedByUser

Первые кандидаты в SIEM: LoginFailedMfaVerifyFailedMfaWebAuthnSignCounterAnomalyDpopReplayDetectedFederationStateValidationFailedClientSecretRotatedAllSessionsRevoked.

Механика: 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)

AuditSqlIntegrationTests

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

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

AuditEavPersistenceIntegrationTests

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

Kafka

AuditKafkaIntegrationTests

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

RabbitMQ

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

AuditAmqpIntegrationTests

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

MQTT

AuditMqttIntegrationTests

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

Elasticsearch

AuditElasticsearchIntegrationTests

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

Redis

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 корректно сериализуются и долетают. Не «архитектурный слайд», а зелёный билд против реальной инфраструктуры.

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-верификаций (methodresult), выданных токенов (grant_typetoken_type), ошибок токенов (error), rate-limit-отклонений и гистограмма времени verify пароля (ловит регрессии CPU на хешировании). Плюс отдельный security-канал логирования (RedbIdentity.Security), чтобы SIEM подписывался на audit-grade события, не просеивая рутинные логи. И health-пробы модуля identity (dbsigning-keysdata-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 IRedbServiceExportService.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, IConfigurationILogger) — повторная инициализация не нужна.

Опция 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 stoptsak 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 без окон даунтайма; хотите затащить свои расширения (IModuleProviderIRouteLifecycleListener, 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-tokendirect-vm://identity-authorizedirect-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)

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, версией протокола, нестандартными маркерами — на отдельном порту и в отдельной сети:

// Управляющий фасад на проприетарном протоколе — отдельный порт, отдельная сеть
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

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

redb.Route

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

redb.Identity

IExternalUserProviderIPasswordHasherIPasswordBreachCheckerIRateLimitStoreIEmailNotificationChannelIBackgroundDeletionServiceIClaimMapper — плюс любой custom RouteBuilder как peer-маршрут

redb.Tsak

IModuleProviderIRouteLifecycleListenerIWatchdogStrategyITsakAuthProvider

redb.Route.Controllers

IControllerDispatcherIActionResultMapper

Везде один паттерн: публичный интерфейс с понятным контрактом + 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 (в промпт уходит IdentityEventevent-typeuser-idclient-idip-address, timestamps; чувствительный материал отфильтрован в EventDispatchProcessor до WireTap). Что получается — очередной .To() / .Filter() / .Process() в вашем RouteBuilder, а не отдельный продукт за доп-лицензию:

Сценарий

Pipeline

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-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. У нас:

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

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

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

Кластерная стабильность — для many-thousand сессий

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).

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. Карта проб по категориям:

redb.Identoty demo
redb.Identoty demo
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
redb.tsak log
redb.tsak log

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


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

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
redb.Identity

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)

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

Loopback HTTP

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 приветствуются.


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

redb.Identity
redb.Identity

Самый быстрый способ — 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-коробка со своим рантаймом и деплоем. И не «напиши своё» в третий раз.

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

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

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

  3. 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.