
Bearer‑токен работает у любого, кто его держит. Разбор трёх слоёв в redb.Identity: BFF без токена в браузере, привязка к ключу через DPoP, быстрый отзыв.
Access‑token по умолчанию предъявительский. Это значит ровно то, как звучит: кто его держит, тот и вы. Сервер не может отличить настоящего владельца от того, кто вытащил токен из чужого браузера. Пока токен не истёк, он работает с любой машины, из любой страны, и в логах это выглядит как обычные легитимные запросы.
Дальше начинается самое неприятное. Средний срок жизни access‑token в реальных конфигурациях от пятнадцати минут до часа. Всё это время у атакующего есть полноценный доступ, и никакого сигнала об этом никто не получит.
Мы строили redb.Identity, свой OAuth 2.1 / OpenID Connect провайдер на.NET, и вопрос «а что если токен украли» пришлось решать не декларативно. Получилось три слоя: не дать украсть, сделать украденное бесполезным, быстро погасить. Ниже разбор каждого, с кодом и с границами применимости.
А дальше сценарий пострашнее: что останется атакующему, который унёс не токен, а всю базу целиком.
Откуда вообще берётся кража
Разговор про кражу токена обычно упирается в XSS, и это правильно, но список шире. Если токен живёт в браузере, до него дотягиваются:
XSS в самом приложении, классика;
XSS через зависимость. Скомпрометированный npm‑пакет получает тот же доступ к странице, что и ваш собственный код. Вы можете написать безупречное приложение и всё равно раздать токены, потому что где‑то в дереве транзитивных зависимостей один пакет сменил владельца. Аудит своего кода этот вектор не закрывает;
расширения браузера. Их ставит пользователь, у них есть доступ к DOM и к сети страницы;
localStorage. Читается любым JavaScript на том же origin, переживает перезагрузку и закрытие вкладки. Хранить там токен это отдельная дисциплина, о которой уже написано достаточно.
Общее у всех четырёх: злоумышленник уносит сам токен. Дальше он ему не нужен ни браузер жертвы, ни её сеть, ни её сессия.
Слой первый: не дать украсть
Как устроены админки большинства identity‑серверов
Возьмите любой зрелый identity‑сервер и посмотрите, как сделана его админ‑консоль. У Keycloak это React‑приложение, у WSO2 Identity Server тоже React (Console и My Account). Обе работают как публичные OAuth‑клиенты: браузер проходит code+PKCE, получает access‑token и держит его у себя.
Это не небрежность, это исторически сложившийся дефолт для SPA. У него есть цена: access‑token с правами администратора identity‑сервера лежит в памяти страницы, доступной JavaScript. Любой из четырёх векторов выше на этой странице означает утечку админского токена того самого сервера, который выдаёт вход во все остальные системы компании.
Показательно, что IETF в актуальном BCP по браузерным приложениям (OAuth 2.0 for Browser-Based Applications) рекомендует именно BFF, а хранение токенов в браузере описывает как схему, от которой стоит уходить. То есть индустрия уже проголосовала, просто зрелые продукты тащат совместимость с тем, что писалось раньше.
Что такое BFF, если коротко
Backend‑for‑Frontend: между браузером и API стоит серверное приложение. Оно проходит OIDC‑обмен по back‑channel, серверу к серверу, и держит токены у себя. Браузеру достаётся HttpOnly‑кука сессии. Токена в браузере нет вообще, и JavaScript до него не дотягивается по определению, а не по договорённости.
Как это сделано у нас
redb.Identity.Web, это референсная админка и личный кабинет. Она построена на Blazor Server плюс cookie‑аутентификация:
builder.Services.AddRazorComponents().AddInteractiveServerComponents(); // ... .AddCookie(options => { options.Cookie.Name = "identity.web.session"; options.Cookie.HttpOnly = true; options.Cookie.SecurePolicy = CookieSecurePolicy.Always; options.Cookie.SameSite = SameSiteMode.Lax; options.ExpireTimeSpan = TimeSpan.FromHours(8); options.SlidingExpiration = true;
Токены после логина кладутся на билет аутентификации, а не отдаются наружу:
AuthenticationTokenExtensions.StoreTokens(props, BuildTokens(result));
Когда серверному коду нужен access‑token, чтобы сходить в Identity, он достаёт его из текущего контекста, а не из запроса браузера:
public async Task<string?> GetAccessTokenAsync(CancellationToken ct = default) { var ctx = _accessor.HttpContext; if (ctx?.User?.Identity is not ClaimsIdentity { IsAuthenticated: true }) return null; return await ctx.GetTokenAsync(CookieAuthenticationDefaults.AuthenticationScheme, "access_token"); }
Промежуточные состояния устроены так же. MFA‑challenge, consent‑challenge, состояние импersonation, это отдельные HttpOnly‑куки под DataProtection, а не поля в localStorage и не query‑параметры:
SameSite = SameSiteMode.Lax, HttpOnly = true,
Blazor Server добавляет ещё один уровень, которого у классического BFF нет. Разметка рендерится на сервере, в браузер уходит только diff по SignalR. JavaScript‑бандла, который держал бы состояние приложения, физически не существует. Атаковать нечего не потому, что хорошо спрятали, а потому, что там ничего не лежит.
Где BFF не помогает
При BFF атакующий, получивший исполнение JavaScript на странице, всё ещё может дёргать API от имени жертвы: кука прикрепляется браузером автоматически. Что он не может, так это унести токен и работать с него потом.
Украденный токен (SPA) |
XSS при BFF |
|
|---|---|---|
Откуда действует атакующий |
со своей машины, откуда угодно |
только из браузера жертвы |
Как долго |
весь срок жизни токена |
пока открыта страница |
Переживает ли закрытие вкладки |
да |
нет |
Можно ли переиграть позже |
да, токен унесён |
нет, уносить нечего |
Шанс поймать аномалией |
выше: чужой IP, чужой user‑agent, чужая гео |
ниже: IP, user‑agent и часы активности совпадают с жертвой |
Последняя строка играет против BFF, и её стоит проговорить отдельно, потому что она ломает удобную картинку. Украденный токен используют с чужой машины, а это ровно тот сигнал, ради которого существует детект невозможного перемещения. XSS в браузере жертвы приходит с её собственного адреса, с её user‑agent, в её рабочие часы, и поведенчески почти неотличим от неё самой. То есть по заметности BFF проигрывает, и симметричный аргумент «другой IP» работает не в нашу пользу.
Оговорка к оговорке: воспользоваться этим преимуществом можно, только если детектор аномалий у вас есть. У нас его пока нет, о чём отдельный разговор ниже.
Итого BFF выигрывает четыре строки из пяти и уверенно проигрывает пятую. Он превращает тихую компрометацию на час из другой страны в активную сессию внутри браузера жертвы, пока она смотрит на экран. Меньший радиус поражения, но не панацея, и уж точно не замена мониторингу.
Взяли куку, вспомнили про CSRF
Это следствие, которое легко забыть на радостях от исчезнувшего токена. Bearer‑заголовок браузер сам никуда не подставляет, а вот куку он прикрепляет к любому запросу на ваш домен, откуда бы этот запрос ни исходил. То есть переход на BFF не убирает риск, а меняет его форму: вместо кражи токена появляется межсайтовая подделка запроса.
Поэтому в BFF стоят обе линии. Куки сессии и все промежуточные состояния идут с SameSite=Lax, HttpOnly и Secure. Поверх этого в пайплайне включён app.UseAntiforgery(), и каждая форма, которая что‑то меняет, несёт <AntiforgeryToken />: вход, MFA‑челлендж, согласие, смена почты, сброс пароля, подтверждение устройства, правка профиля. Двенадцать форм, ни одной без токена.
Обе линии нужны именно вместе. SameSite=Lax закрывает межсайтовые POST‑запросы, но полной заменой антифорджери‑токену не является: он не спасает от атаки с вашего же поддомена и перестаёт работать, если какому‑то потоку понадобился SameSite=None. Правило простое: взяли куку, заводите CSRF‑защиту, независимо от того, насколько хороши остальные флаги.
А что делать мобильному клиенту
BFF это браузерный паттерн, и натив в него не укладывается. Более того, предлагать мобильному приложению встроить веб‑вью ради BFF значило бы прямо нарушить RFC 8252: он требует для авторизации системный браузер, а не собственный компонент внутри приложения.
Для натива работает другая комбинация:
системный браузер для авторизации, по RFC 8252, плюс PKCE, обязательный в OAuth 2.1;
хранилище ключей операционной системы для токенов, Keychain и Keystore, а не файл в песочнице приложения;
слой второй, то есть DPoP, и здесь он несёт основную нагрузку. Приватный ключ генерируется в защищённом хранилище устройства и оттуда не извлекается, поэтому украденный из приложения токен без него бесполезен.
Формулировать точнее так: BFF решает задачу для браузера, DPoP решает её для всего остального. У браузера есть где спрятать токен на сервере, у мобильного приложения нет, поэтому там защищают не хранение, а применение.
Слой второй: сделать кражу бесполезной
Здесь работает DPoP, RFC 9449, Demonstrating Proof‑of‑Possession. Идея простая и радикальная: перестать выдавать предъявительские токены.
Клиент генерирует пару ключей и оставляет приватный у себя. При запросе токена он показывает публичный, и сервер вшивает его отпечаток в выданный токен. Дальше каждый запрос к ресурсу несёт отдельный короткий JWT‑proof, подписанный приватным ключом и привязанный к конкретному методу и URL:
POST /api/orders HTTP/1.1 Authorization: DPoP eyJhbGciOiJSUzI1NiIsInR5cCI6... DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2Iiwiandr...
Украли токен без приватного ключа, получили бесполезную строку. Сервер потребует proof, а подписать его нечем.
Что реализовано у нас:
привязка при выдаче. Отпечаток ключа по RFC 7638 (SHA-256 от JWK, base64url) кладётся в claim
cnf.jktсогласно RFC 7800. Токен теперь знает, к какому ключу привязан;DPoP-Nonceпо § 8, на HMAC‑подписанных stateless‑нонсах. Сервер может потребовать, чтобы proof был свежим, и при этом не держать состояние по каждому клиенту;хранилище использованных
jtiв разрезеjkt. Один и тот же proof дважды не пройдёт, даже если его перехватили целиком;только асимметричные алгоритмы, по allow‑list, как требует § 4.2. Симметричный «proof» не доказывает ничего, потому что ключ известен обеим сторонам;
отдельный пакет
redb.Identity.Resource.Dpop. Это валидатор для ваших собственных ресурсных API. Проверять proof должен не только сам провайдер, иначе защита кончается на границе identity‑сервера, а живут данные дальше.
Отдельно про сравнения. DPoP сейчас есть и у Keycloak, и у WSO2. Появился он там позже, и у Keycloak долго держался в статусе preview, но говорить «у них этого нет» неправильно. Правильная формулировка звучит иначе: проверьте по своей конкретной версии, включено ли и в каком статусе, потому что preview‑фича в проде это отдельный разговор с вашим же ИБ.
Слой третий: погасить быстро
Даже идеальная привязка не отменяет необходимости отозвать доступ. Пользователь уволился, устройство потеряли, инцидент подтвердился. Дальше вопрос один: сколько времени пройдёт, пока отзыв реально доедет до всех систем.
Это, кстати, любимый пункт корпоративных технических заданий, и обычно он в них записан как открытый вопрос, а не как решение.
Что есть:
отзыв токенов по RFC 7009, идемпотентный: повторный отзыв возвращает 200, как требует § 2.1, и не превращается в источник ошибок в скриптах;
ротация refresh‑токенов. Использованный refresh отзывается, и повторное предъявление означает, что кто‑то работает с копией;
idle‑timeout сессии. Каждая сессия несёт время последней активности, обновляемое на реальных действиях: обмен refresh, валидация куки, вызов
userinfo. Просроченная по бездействию сессия гасится автоматически;backchannel logout в двух режимах. Это интереснее всего, поэтому подробнее.
Классический OIDC Backchannel Logout это push: провайдер стучится в каждое зарегистрированное приложение и сообщает, что сессия завершена. Схема работает ровно до первого сетевого разрыва или упавшей реплики. Приложение, до которого стук не дошёл, продолжает считать сессию живой.
Поэтому у нас рядом с push живёт pull‑фид отозванных идентификаторов сессий, с курсором:
POST /api/v1/identity/revoked-sids/add GET /api/v1/identity/revoked-sids/since?cursor=...
Приложение, поднявшееся после падения или потерявшее связь на десять минут, просто спрашивает «что отозвали с момента моего курсора» и догоняет пропущенное. Ни один отзыв не теряется из‑за того, что в момент рассылки узел был недоступен.
И приятная деталь, которая связывает первый слой с третьим. BFF проверяет этот же список на каждом запросе при валидации сессионной куки:
options.Events.OnValidatePrincipal = async ctx => { var sid = ctx.Principal?.FindFirst("sid")?.Value; var sub = ctx.Principal?.FindFirst("sub")?.Value; // если sid/sub в кластерном списке отозванных, кука сбрасывается
То есть «выйти везде» гасит не только токены, но и живую сессию в интерфейсе, причём на всех репликах, а не только на той, куда пришёл запрос на выход.
Рутина вокруг
Три слоя это верхушка. Ниже лежит работа, которую видно только если специально смотреть. Перечислю то, что считаю содержательным.
История паролей. Настраиваемая глубина, повтор ранее использованного пароля отклоняется. Хэшируется тем же алгоритмом, что и основной.
TOTP с защитой от переигрывания. Проблема стандартной реализации TOTP: код действителен внутри окна допуска, и один и тот же код можно предъявить дважды, если успеть. У нас последний принятый шаг сохраняется, и код из уже использованного шага отвергается, даже когда формально попадает в окно:
if (props.LastTotpStep.HasValue && step <= props.LastTotpStep.Value) return false;
Плюс строка MFA берётся под блокировку до проверки, чтобы параллельные попытки одного пользователя не разъезжались на чтении‑записи.
Одноразовые пароли SMS и email лежат на сервере, а не в состоянии. Сам код хранится хешированным (SHA-256), помечается использованным под LockForUpdate, а в зашифрованное состояние challenge уходит только ссылка на него. Клиент не носит с собой ничего, из чего можно восстановить код.
Recovery‑коды одноразовые по‑настоящему. Помечаются использованными в той же транзакции, в которой создаётся сессия. Не «сначала пометили, потом создали», а атомарно, иначе на разрыве между двумя операциями появляется окно.
Сравнения секретов в постоянном времени. CryptographicOperations.FixedTimeEquals встречается в шестнадцати файлах: токены сброса пароля, подтверждения почты, смены почты, серверные OTP, bootstrap‑секрет. Тайминг‑атака на сравнение строк это не экзотика, это то, что находят автоматические сканеры.
Rate limiting на трёх уровнях: по IP, по client_id через token bucket, и отдельный потолок неудачных попыток по паре «IP плюс пользователь» с записью в отдельный канал безопасности.
Порядок обработки заголовков прокси. X-Forwarded-For санируется до того, как его увидят rate limiter и счётчик блокировок. Если сделать наоборот, атакующий подделывает заголовок и обходит оба, а заодно может подставить чужой IP под блокировку. Работает только когда сокетный пир в белом списке доверенных прокси, иначе заголовок игнорируется целиком.
Кэш идемпотентности стоит после авторизации, а не до. Иначе отозванный токен разблокирует закэшированный ответ, который выдали ещё живому.
Приставка __Host- у кук ставится только при Secure=true. Это требование RFC 6265bis § 4.1.3.2, и его легко нарушить: выставить приставку, забыть флаг, и получить куку, которую браузер молча отбросит, а вы будете искать причину в коде.
Защита от SSRF на исходящих запросах. Есть ровно две ситуации, когда клиент может заставить наш сервер сходить по своей ссылке: разрешение jwks_uri и загрузка объекта запроса по request_uri. Обе закрыты общим guard'ом, который отвергает loopback, приватные диапазоны RFC 1918, link‑local вместе с адресом облачных метаданных 169.254.169.254, и CGNAT‑пространство RFC 6598. Открыть приватные адреса можно только явным флагом, для однохостовых тестовых стендов.
Отказ от alg:none. Наш JAR (RFC 9101) принимает подписанный объект запроса, проверяет подпись по ключам клиента и берёт параметры изнутри JWT. Неподписанный объект отвергается всегда, независимо от настроек: он выбрасывает ровно ту гарантию целостности, ради которой JAR и существует. FAPI 2.0 запрещает его прямо.
Сто девять типизированных событий аудита в семи категориях, в плоскую таблицу и опционально мультикастом в Kafka, Elasticsearch, RabbitMQ. Пароли и секреты в аудит не пишутся никогда: ротация клиентского секрета оставляет отметку о факте, а не значение.
А если украли всю базу
До сих пор речь шла про один токен. Теперь худший сценарий: у злоумышленника дамп базы целиком.
Модель угрозы меняется полностью. Никаких rate‑limit'ов, никакого аудита, никакого отзыва. Атакующий работает офлайн, у себя, столько времени, сколько захочет, и вы об этом не узнаете. Всё, что защищает данные в этот момент, это форма, в которой они лежат.
Пароли
Хэшируются Argon2id, параметрами OWASP 2023: 64 МиБ памяти, 3 итерации, 4 полосы, соль 16 байт на пользователя, хэш 32 байта.
Три свойства, которые это даёт. Пароль не шифруется, а хэшируется: ключа, которым можно расшифровать всё разом, не существует в природе. Соль своя у каждого: радужные таблицы бесполезны, и нельзя одним проходом вскрыть всех, у кого пароль qwerty123. Вычисление намеренно медленное: вместо миллиардов попыток в секунду атакующий получает единицы.
Отдельно про память, и это главное отличие от bcrypt. Bcrypt требует около 4 КБ на вычисление. На видеокарте или на заказном ASIC помещаются десятки тысяч параллельных экземпляров, и разрыв между защитником на обычном процессоре и атакующим на ферме получается огромный. Argon2id с 64 МиБ ломает эту экономику арифметически: в 24 ГБ видеопамяти влезает порядка 380 потоков вместо десятков тысяч. Именно поэтому Argon2id выиграл Password Hashing Competition и стоит у OWASP первым номером.
Bcrypt при этом никуда не делся и это осознанно. Слой хранения redb.Core исторически хэшировал пароли bcrypt с work factor 12, и у существующих внедрений база именно такая. Поэтому Identity ставит диспетчер, который пишет новые хэши Argon2id, но умеет проверять и старые bcrypt, и совсем древний SHA256 с солью:
builder.Services.TryAddSingleton<IPasswordHasher>(sp => { var argon2 = new Argon2idPasswordHasher(...); // 64 MiB, t=3, p=4 var bcrypt = new BcryptPasswordHasher(workFactor: opts.Bcrypt.WorkFactor); if (opts.Algorithm == PasswordHashAlgorithm.Bcrypt) return bcrypt; return new MultiFormatPasswordHasher(argon2, bcrypt); });
Плюс работает upgrade‑on‑login: при успешном входе хэшер спрашивают, не устарел ли формат, и если да, пароль тихо перехэшировывается в Argon2id и сохраняется. База переезжает сама, по мере того как люди логинятся. Никаких принудительных сбросов пароля для всех разом, которые пользователи ненавидят, а служба поддержки переживает как стихийное бедствие.
Всё остальное, что лежит в базе
Пароли, это одна строка из многих. Полная опись:
Что лежит |
В каком виде |
|---|---|
Пароли пользователей |
Argon2id, легаси bcrypt до первого входа |
История паролей |
тем же хэшером, что и основной |
Секреты OAuth‑клиентов |
bcrypt‑хэш, восстановлению не подлежит |
TOTP‑секреты |
зашифрованы |
Recovery‑коды |
PBKDF2-HMAC‑SHA256, соль на каждый код, плюс pepper |
Одноразовые коды SMS и email |
SHA-256, одноразовые, гасятся под блокировкой |
Токены сброса пароля, подтверждения и смены почты |
хэшированы, сверка в постоянном времени |
Приватные ключи подписи |
зашифрованы через DataProtection |
Key‑ring DataProtection |
зашифрован на покое |
Два места здесь принципиальные, и на них стоит остановиться.
Pepper лежит не в базе
Recovery‑коды защищены не только солью. В формулу входит pepper, отдельный секрет, который подаётся переменной окружения и в таблице не хранится вообще.
Разница между солью и pepper'ом ровно в этом. Соль лежит рядом с хэшем и защищает от радужных таблиц, но перебору не мешает. Pepper рядом не лежит. Имея только дамп, перебирать recovery‑коды нечем: в формуле участвует то, чего в дампе нет.
Цепочка ключей упирается в корень, которого в дампе нет
Вот это самое важное при краже базы identity‑сервера. Худший исход не «вскрыли пароли», а «получили приватный ключ подписи». С ним подделывается токен от имени кого угодно, включая администратора, и никакая защита паролей уже не помогает.
Приватные ключи подписи лежат зашифрованными: поле EncryptedPem, защита через IDataProtector.Protect с отдельным назначением. Логичный следующий вопрос: а чем защищён сам key‑ring DataProtection, если он тоже хранится в базе. Иначе получился бы замкнутый круг, где замок и ключ лежат в одном ящике.
Круга нет. Key‑ring шифруется на покое одним из трёх способов: X.509-сертификатом, 32-байтовым AES‑GCM мастер‑ключом, или собственным хуком в KMS и Vault. И это не рекомендация в документации, а условие запуска:
if (dp.RequireAtRestEncryption && !options.AllowEphemeralKeys) { throw new InvalidOperationException( "DataProtection key-ring is unprotected at rest. Configure ONE of: ..."); }
RequireAtRestEncryption по умолчанию true. Сервер отказывается стартовать в проде, если key‑ring не защищён. Отключить проверку можно только явно, и настройка называется так, что случайно её не выставишь.
Итог по цепочке: дамп даёт зашифрованные ключи подписи, зашифрованный key‑ring, и корневой ключ в дампе отсутствует. Ciphertext до самого низа.
Чего кража базы всё‑таки достигает
Без него предыдущие абзацы были бы враньём по умолчанию.
Шифрование не отменяет утечку. Из дампа спокойно читаются персональные данные (ФИО, почты, телефоны, отделы, руководители, табельные номера), структура организации (группы, роли, кто к чему допущен), метаданные сессий (IP, устройства, время активности, то есть кто откуда работает) и весь аудит целиком.
По российскому праву это утечка персональных данных со всеми вытекающими обязанностями, независимо от того, что ни один пароль не вскрыт. Говорить «у нас всё зашифровано» в такой ситуации значит закрывать тему, которую закрывать нельзя.
И вторая оговорка, операционная. Вся цепочка держится на том, что корневой ключ хранится отдельно от базы. Если резервная копия снимается вместе с файлом конфигурации или с переменными окружения контейнера, защита складывается в ноль: атакующий получил и ящик, и ключ. Бэкапы базы и хранилище секретов должны жить в разных контурах с разными правами доступа. RequireAtRestEncryption защищает от кражи дампа, а не от кражи бэкапа целиком, и это разные вещи.
Что вообще выставлено наружу
Отдельный вопрос, который часто важнее всех криптографических деталей: какая часть системы вообще доступна из интернета.
У большинства identity‑серверов админ‑консоль живёт в том же процессе и на том же порту, что и протокольные эндпоинты, по пути вида /admin. Изолировать её можно, для этого есть настройки хоста и обратный прокси, но разделение получается по URL и по конфигурации. Ошибка в правилах прокси или регрессия в настройках хоста, и плоскость управления оказалась снаружи.
У нас разделение проходит по процессам и портам.
Ядро вообще не сетевое. Все эндпоинты redb.Identity.Core живут на маршрутах direct-vm://, внутрипроцессном транспорте. Это не «слушает на localhost», до ядра нет сетевого пути в принципе, кроме как через явно поднятый фасад.
Внутри HTTP‑фасада плоскости разведены по портам:
"Http": { "PublicPort": 5002, "ManagementPort": null }
PublicPort несёт только /connect/* и /.well-known/*. ManagementPort несёт /api/v1/identity/* и /scim/*, и в комментарии к настройке прямым текстом написано, что в проде это должен быть отдельный порт за файрволом. SCIM вдобавок гасится отдельным флагом и может не подниматься вовсе.
Аварийный bootstrap‑эндпоинт вынесен отдельно. POST /internal/bootstrap-admin живёт на management‑порту, но вне базового пути /api/v1/identity/, чтобы его можно было закрыть правилом файрвола независимо от всего остального. Аутентификации по bearer у него нет по устройству, защита идёт заголовком с секретом, который сравнивается в постоянном времени. CORS на нём отключён намеренно: это back‑channel инструмент оператора, из браузера он не вызывается никогда.
Интерфейс это отдельное приложение. redb.Identity.Web, самостоятельный ASP.NET‑хост, который ходит в Identity серверным HTTP‑клиентом. Другая машина, другой сегмент сети, или вовсе не разворачивается: Identity от этого не перестаёт работать. Поставляется он исходниками, а не пакетом, именно потому что это референс, который вы правите под себя.
Смысл всей конструкции в цене ошибки. Чтобы выставить наружу плоскость управления, у нас нужно сознательно поднять management‑порт в интернет или развернуть Web в DMZ. Это трудно сделать случайно.
Внешний арбитр
Всё написанное выше можно утверждать про любой сервер, и обычно так и делают. Проблема в том, что своя проверка находит ровно те баги, про которые вы знали заранее.
Мы прогнали сервер через официальный conformance‑suite OpenID Foundation, тот же, которым Фонд сертифицирует провайдеров. Config OP проходит без провалов. Basic OP, это тридцать пять модулей, и FAILED среди них ноль.
Гораздо интереснее то, что он в нас нашёл. Claim'ы из scope (телефон, адрес) попадали в id_token, а id_token пересылается третьим сторонам и пишется в логи как доказательство входа. То есть номер телефона пользователя ездил заметно дальше, чем клиент вообще запрашивал. Это утечка персональных данных, и ни один наш тест её не ловил, потому что мы не знали, что это баг. Подробный разбор того прогона, включая настройку сьюта и полный список найденного, есть отдельной статьёй: как мы прогоняли свой OpenID‑сервер через официальный сьют.
Два модуля в отчёте помечены SKIPPED, и оба про неподписанные объекты запроса с alg:none. Пропущены они потому, что сервер отказывается рекламировать небезопасный режим. Читать в conformance‑отчёте нужно причину, а не цвет строки.
Марку OpenID Certified™ мы не носим: это товарный знак, он выдаётся Фондом по отдельной платной процедуре. Утверждается ровно то, что верно: сервер прогоняется через официальный сьют, и вот результаты.
Чего пока нет
Раздел, без которого статья была бы рекламой.
Риск‑ориентированная аутентификация. Входные данные для неё уже лежат: IP сессии, user‑agent, читаемая метка устройства, история входов в аудите, счётчики неудачных попыток. Чего нет, так это движка правил и политики усиления поверх них.
Точнее всего это видно на acr. Сервер сообщает достигнутый уровень: acr со значением 1 для однофакторного входа и 2 для подтверждённого многофакторного, плюс amr с конкретным методом (pwd, otp, mfa, hwk). А вот входящий acr_values как требование поднять уровень он не отрабатывает: параметр объявлен в discovery, но заставить сервер переспросить второй фактор им нельзя. Ровно этого и не хватает для шаг‑апа, вместе с RFC 9470.
Доверенные устройства как отдельная сущность. Устройство фиксируется в сессии, но записи «этому устройству доверяем до такой‑то даты» пока нет.
SAML 2.0. Не реализован ни в одну сторону. Решение осознанное и записанное: для современных интеграций хватает OIDC‑федерации, а полноценный SAML это XML‑подписи, метаданные, три вида биндингов и Single Logout со всеми его особенностями. Условия, при которых мы за это возьмёмся, тоже записаны, и первое из них это реальный клиент с enterprise‑IdP, который не отдаёт OIDC.
Из RFC: Rich Authorization Requests (9396), профиль JWT для access‑токенов (9068, заголовок typ=at+jwt), параметр iss в ответе авторизации (9207), шаг‑ап (9470). Плюс CIBA и OIDC Federation 1.0.
Отдельно про mTLS, потому что здесь легко ввести в заблуждение. Транспортный mTLS у нас есть: gRPC‑фасад умеет требовать клиентский сертификат и пинить его по отпечатку из белого списка, и для management‑порта это рекомендованная продовая настройка. Чего нет, так это RFC 8705, а это другое: mTLS как способ аутентификации OAuth‑клиента на token‑эндпоинте плюс привязка выданного токена к сертификату через cnf.x5t#S256. Первое защищает канал, второе сделало бы токен таким же непереносимым, каким его делает DPoP. Мы пошли путём DPoP.
FAPI 2.0 как профиль не заявляем. Отдельные кирпичи из него на месте: обязательный PAR можно включить для конкретного клиента, alg:none отвергается всегда, для объектов запроса разрешена только асимметрика. Но профиль это не набор галочек, а прохождение соответствующего conformance‑плана, и мы его не проходили. Если ваш ИБ требует FAPI, считайте это отдельным объёмом работ, и лучше узнать сейчас.
Что забрать с собой
Если из статьи стоит унести три мысли, то вот они.
Первая. Токен в браузере, это выбор, а не необходимость. BFF сегодня рекомендованная схема для браузерных приложений, и переход на неё меняет не вероятность компрометации, а её радиус: вместо тихого доступа с чужой машины на час вы получаете активность внутри браузера жертвы, ограниченную временем открытой вкладки. Цена размена в том, что такая активность хуже ловится аномалией, и это стоит держать в голове, а не считать BFF бесплатным улучшением.
Вторая. Предъявительский токен, это тоже выбор. DPoP превращает украденную строку в бесполезную, включается он на стороне провайдера, а не переписыванием всех клиентов сразу, и работает там, где BFF неприменим: в мобильном и десктопном нативе, в сервис‑сервисных вызовах. Проверьте, есть ли он у вашего сервера, и в каком он статусе.
Третья. Скорость отзыва решается архитектурой, а не сроком жизни токена. Push‑уведомления приложениям ломаются на первом же сетевом разрыве. Pull‑фид с курсором переживает и разрыв, и падение узла, и стоит недорого.
Четвёртая, про кражу базы. Проверьте у своего сервера две вещи, и обе занимают пять минут. Первая: чем хэшируются пароли и есть ли upgrade‑on‑login, потому что без него база не переезжает на современный алгоритм никогда. Вторая, и она важнее: где лежит ключ, которым зашифрованы приватные ключи подписи. Если он в той же базе, шифрование декоративное, и худший сценарий при краже дампа открыт.
redb.Identity открыт под Apache 2.0, работает на PostgreSQL, MS SQL и SQLite без изменений в коде, и запускается как внутрипроцессный модуль, если сеть между вашими сервисами вам не нужна. Посмотреть можно на GitHub.
Если было полезно, ⭐ на GitHub поможет другим это найти.
Другие мои статьи: redb.ru/articles, ещё на Хабре.
Комментарии (10)

Gromilo
08.09.2026 15:54Честная граница
От честностей в статьях хабра уже тошнит :(
Заметно ли в логах
нет, обычный трафик
трафик с IP жертвы, ловится поведенчески
А почему с токеном не ловится поведенчески и куда делось симметричное "другой IP".
Вообще, берёшь куку, будь добр помнить CSRF/XSRF, не смотря на все защиты куки.
grelikt Автор
08.09.2026 15:54По второму пункту вы правы, и это была ошибка в мою пользу. Строку про заметность я написал задом наперёд: украденный токен идёт с чужого IP, чужого user-agent и чужой геолокации, то есть аномалией ловится лучше. А XSS в браузере жертвы приходит с её собственного адреса, в её рабочие часы, и поведенчески почти неотличим от неё самой, то есть ловится хуже. Симметричный аргумент «другой IP» я применил только там, где он мне выгоден, и не применил там, где он играет против.
Хуже того, фраза «ловится поведенчески» противоречит моему же разделу ниже, где написано, что детектора аномалий у нас пока нет. Приписал себе механизм, отсутствие которого сам же и признал.
Таблицу переписал: теперь по заметности BFF проигрывает. Он выигрывает четыре строки из пяти, а не пять из пяти, и так гораздо ближе к правде, хотя в реале всёж BFF как не крути лучше.
Про CSRF согласен, в статье он мелькал в скобках, хотя заслуживает отдельного места. Смысл ровно ваш: переход на куки не убирает риск, а меняет его форму. У нас стоят обе линии,
SameSite=LaxплюсSecureиHttpOnlyна куках, и поверхapp.UseAntiforgery()с токеном в каждой изменяющей форме. Именно вместе, потому чтоSameSiteне спасает от атаки с собственного поддомена и отваливается, если какому-то потоку понадобилсяSameSite=None. Вынес в отдельный подраздел.Про первую фразу принято, слово убрал из текста.

segment
08.09.2026 15:54Теперь мы на хабре разговариваем с ботами? С ChatGPT я могу и сам поговорить

grelikt Автор
08.09.2026 15:54не, ну можно конечно и с самим собой поговорить.
тут ведь как, статья помечена - сложно.
я уже не однократно писал что не копирайтер.
шлифует да нейронка. так как из меня так себе писатель.
я пытаюсь донести основную мысль.
откуда взялась статья, сегодня с коллегой обсуждали SSO в компании,
и проговаривали что и где может продырявится....
и вот как результат беседы я выложил на нейронку чтоб отразило ровненько мои мысли.однако прошу заметить тут не нейрослоп. а вполне можно зайти и посмотреть исходники и уже аргументированно критиковать.

segment
08.09.2026 15:54если выбирать между статьей написанной Вами пускай даже не писателем и статьей от LLM — я выберу Вашу. Тем более тут все-таки про технические детали. А LLM можно использовать как планировщик.

grelikt Автор
08.09.2026 15:54я боюсь что я буду писать это достаточно долго, и не очень продуктивно. и потому в свет либо она никогда не выйдет, либо через пару лет, и к тому же мне за это не платят.
а в наш век нейронок наверное надо использовать этот инструмент, оно экономит кучу времени.
и самое главное, я всеже читаю что у меня получилось, перед тем как публиковать, потому как у нейронок большая и больная фантазия.. пока по крайней мере.
segment
08.09.2026 15:54Понимаю доводы. Но лично у меня, когда я вижу AI статью я уже теряю интерес к чтению, так как автоматически уровень доверия к информации падает. Потому что автор мог не вычитать ее, либо там откровенный слоп — разбираться уже не хочется.

grelikt Автор
08.09.2026 15:54нейрослоп я сам не люблю, потому что это просто набор символов без цели.
у меня же есть техническая база и определенные цели.
я приглашаю аргументированно к дискуссии и задавать вопросы на github.com/redbase-appтам нет статей, всё очень конкретно и точечно.
4external
всё по красоте, но вы предлагаете мобильным клиентам реализовывать небольшой web-browser в своих сетевых слоях?
grelikt Автор
Нет конечно, это будет по типу оверинж: в статье этого не было, а надо было. BFF это браузерный паттерн, натив в него не укладывается, и встроенный веб-вью ради него был бы прямым нарушением RFC 8252, который требует системный браузер.
Для натива работает другая связка: системный браузер плюс PKCE для авторизации, хранилище ключей ОС (Keychain, Keystore) для токенов, и DPoP как основная защита. У DPoP приватный ключ генерируется в защищённом хранилище устройства и оттуда не извлекается, поэтому украденный из приложения токен без него бесполезен. Это второй слой из статьи, и для мобильных он несёт основную нагрузку, а не BFF.
Если формулировать одной фразой: BFF решает задачу для браузера, DPoP для всего остального. У браузера есть куда спрятать токен на сервере, у мобильного приложения нет, поэтому там защищают не хранение, а применение. Добавил в статью отдельный подраздел.