Перейти к основному содержимому

ADR 0032 — Отказ от Keycloak: собственный эмитент, service token и сессия в cookie

Контекст​

ADR 0031 построил first-party auth параллельно Keycloak: argon2id, access token EdDSA, ротация refresh с обнаружением повторного использования, блокировка, токены учётной записи. Всё было готово, протестировано и не имело ни одного потребителя — глобальный JwtAuthGuard продолжал проверять токен Keycloak, а маршруты /auth/* существовали, но ими никто не пользовался.

Этот ADR фиксирует переход: замену эмитента, замену client-credentials во внутреннем трафике, собственный вход в web и удаление Keycloak из compose, манифестов, скриптов и документации.

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

  1. Ни одно решение RBAC не читает claim токена. RolesGuard, ResolveEffectiveRoleUseCase и decide() зависят от request.user.id и строк в базе; realm_access и resource_access никогда не использовались. Матрица прав структурно невосприимчива к смене эмитента — что превратило критерий «идентичная матрица» из аудиторской работы в дешёвую проверку.
  2. request.clientId был единственным claim, доживавшим до авторизации. Он приходил из azp Keycloak и управлял двумя вещами: EngineServiceGuard 26 маршрутов /internal/* и освобождением engine от rate limit. У first-party токена нет azp, и без обработки этого переход закрыл бы все 26 маршрутов разом.
  3. Повторное использование SyncUserUseCase дало бы 500, а не 401. sub нового токена — это сам users.id, но upsertFromKeycloak делает конфликт по keycloak_sub — вставка нарушила бы users_email_lower_idx, а throw происходит ВНЕ try/catch, который оборачивает только проверку токена.

Решение​

Переход атомарный: периода сосуществования нет​

В одном релизе эмитент меняется, а Keycloak уходит. Все разлогиниваются; у кого уже была учётная запись, впервые задают пароль.

Альтернатива — guard, принимающий оба эмитента несколько недель, — стоила бы двух путей сессии в web, двух сетевых конфигураций и двух наборов тестов, и всё это должно было бы работать каждый день, пока кто-то не решит закончить. Цена перехода — объявленный коллективный logout, один раз; цена сосуществования — бессрочный долг. Идёт как breaking change в CHANGELOG.

Guard читает, а не синхронизирует​

FirstPartyTokenVerifier делегирует Ed25519AccessTokenIssuer и возвращает { sub: userId, email }. JwtAuthGuard переходит на UserRepository.findById, с 401, когда пользователь не найден, — валидный токен, чей sub исчез, это осиротевшая сессия (учётная запись удалена в пределах 15-минутного окна), и 401 отправляет клиента на вход, а не превращается в инфраструктурный алерт.

SyncUserUseCase, upsertFromKeycloak и KeycloakTokenVerifier удалены. Исчезает одна запись в базу на каждый аутентифицированный запрос.

users.keycloak_sub остаётся в этой поставке. Это единственное оставшееся свидетельство происхождения, и именно его использует скрипт миграции, чтобы отличить «перенесённую учётную запись, ждущую пароль» от «заброшенной записи». Миграция, которая её удаляет, придёт после того, как переход устоится.

/internal/* уходит из JWT​

Новый декоратор @ServiceRoute(), учитываемый JwtAuthGuard (не требует Bearer) и RateLimitGuard (освобождает). Освобождение должно идти от метаданных, а не от guard: RateLimitGuard — это APP_GUARD и выполняется раньше любого guard контроллера, поэтому, когда он принимает решение, EngineServiceGuard ещё не выполнялся.

EngineServiceGuard сохранил имя класса и сменил тело: проверяет X-Brabo-Service-Token за постоянное время. Сохранение имени — не привязанность: именно это позволяет route-surface.spec.ts по-прежнему классифицировать 26 маршрутов как engine-service и избегает 26 строк изменений в docs/security-surface.md, которые спрятали бы посреди diff любое реальное изменение экспозиции.

request.clientId удалён из AuthenticatedRequest. Без azp и без потребителя поле идентичности, которое никогда ничего не значит, — это приглашение кому-нибудь снова вставить его в проверку авторизации.

Один секрет, BRABO_SERVICE_TOKEN, в обе стороны. Два отдельных секрета ограничили бы ущерб от утечки одним направлением — но обе стороны работают в одном кластере, разворачиваются вместе и читают один и тот же Secret: кто читает один, читает и другой. Второй создавал бы видимость разделения, ничего не разделяя, и удваивал бы то, что нужно ротировать синхронно. BRABO_SERVICE_TOKEN_PREVIOUS принимается только при проверке, так что обе стороны можно обновлять в любом порядке.

Собственный заголовок вместо Authorization: Bearer, потому что в этом заголовке «JWT пользователя» — устоявшееся значение во всём остальном api, и неоднозначность привела бы к тому, что кто-то отправил бы сервисный токен на пользовательский маршрут.

Со стороны Elixir: VerifyServiceToken заменяет VerifyApiToken, сохраняя контракт 401 + JSON + halt() (от этого зависят три утверждения route_surface_test.exs), восемь сборок заголовка превращаются в одну, и уходят joken, joken_jwks, jose и tesla.

POST /auth/login перестаёт возвращать refreshToken в теле. Refresh передаётся в cookie httpOnly, SameSite=Strict, Path=/auth, Secure в продакшене; access token по-прежнему хранится в памяти JS и в Authorization: Bearer.

Возвращать refresh ещё и в теле означало бы свести на нет всю защиту: достаточно было бы XSS прочитать ответ входа. Именно это и произошло бы с очевидной альтернативой — localStorage, — с тем отягчающим обстоятельством, что XSS унёс бы длинную сессию (30 дней семейства), а не 15 минут access.

Держать access token ВНЕ cookie — это то, что избавляет от необходимости требовать CSRF на каждом аутентифицированном маршруте: он нужен только для /auth/*.

CSRF через double-submit, даже с SameSite=Strict. Атрибут сам по себе уже не даёт браузеру прикрепить cookie к запросу, исходящему с другого сайта, что закрывает CSRF на этих маршрутах. Второй слой платит за три вещи, которые он не покрывает: браузер, игнорирующий атрибут, скомпрометированный поддомен (который для cookie является «same site»), и день, когда кому-то понадобится ослабить до Lax из-за redirect-потока. Пара — это читаемая из JS cookie (brabo_csrf), повторяемая в X-CSRF-Token: кто находится на другом origin, не может ПРОЧИТАТЬ cookie и поэтому не может собрать заголовок.

Отказ CSRF — это 403, а не 401: 401 говорил бы «ваши учётные данные не годятся», и клиент пытался бы обновить сессию, уходя в цикл.

Refresh в single-flight — требование, а не оптимизация​

ADR 0031 уже это зафиксировал, а здесь это реализовано: одно обещание в полёте, общее для всех вызывающих.

Без этого система разлогинивает пользователя при обычном использовании. Два вызова, одновременно получившие 401, запустили бы два refresh; второй предъявил бы токен, который первый уже израсходовал, — что на стороне сервера является ТОЧНОЙ сигнатурой кражи. Семейство отзывается, и пользователь возвращается на вход за то, что открыл два параллельных запроса.

Перенесённого пользователя невозможно отличить​

Вход учётной записи, импортированной из Keycloak (есть в users, нет строки в auth_credentials), отвечает единообразным 401, идентичным ответу на несуществующий e-mail, и молча отправляет ссылку «задать пароль».

Ответ с явным password_pending был бы очевидным UX и отвергнут: он подтвердил бы, что адрес существует, и что это унаследованная учётная запись — самый ценный сигнал перебора в системе, и ровно то, что закрывает RN-032.

Чтобы стоимость была одинаковой во всех трёх исходах, findByEmail стал LEFT JOIN от users к auth_credentials: один запрос. Два последовательных запроса заставили бы ветку ожидания платить лишнее обращение к базе, и по времени можно было бы отличить перенесённую учётную запись от несуществующего e-mail.

«Ожидание» — это производное состояние, а не колонка: нет password_pending, который мог бы рассинхронизироваться, и идемпотентность скрипта миграции достаётся бесплатно.

Миграция не подключается к Keycloak​

Потому что импортировать нечего. JwtAuthGuard делал upsert каждого пользователя в users при каждом запросе начиная с Фазы 1 — id, e-mail и связи RBAC всегда были в базе api. Keycloak никогда не был источником истины для RBAC; он был эмитентом токена. Этим учётным записям не хватает пароля, которого он тоже не дал бы (хеш пароля не переносится, решение CLAUDE.md).

Последствия​

Все разлогиниваются при релизе. Это объявленная цена атомарного перехода, и она указана в CHANGELOG как несовместимое изменение.

Ссылки «задать пароль» попадают в лог api, а не во входящие. MailSender остаётся log-only, а реальный SMTP по-прежнему — будущая настройка. Для установки с единственным владельцем этого достаточно; runbook объясняет, как извлечь их с AUTH_MAIL_LOG_TOKENS=true. Это самое заметное ограничение этой поставки.

Ротация AUTH_TOKEN_PEPPER или BRABO_SERVICE_TOKEN имеет противоположные и одинаково резкие эффекты. Первая разлогинивает всех и делает недействительными открытые ссылки; вторая, если сделана только с одной стороны, обрывает внутренний трафик. Обе описаны в runbook, с _PREVIOUS, задокументированным как способ сделать это без простоя.

Web не может сказать «этот e-mail уже используется». Последствие, унаследованное от ADR 0031, теперь с экраном: форма говорит «если адрес доступен».

Smoke-тест зависит от подготовленного пользователя. Без внешнего IdP готовых учётных данных больше нет, а регистрация через API упирается в подтверждение e-mail. bootstrap.sh запускает seed, который создаёт учётную запись с известным паролем и уже подтверждённым e-mail, — и provisionarUsuario отказывается работать с NODE_ENV=production без явного переопределения. Это инструмент разработки, и в коде он помечен как таковой.

users.keycloak_sub остаётся в схеме без какого-либо эмитента за ним. Это осознанный долг, с целью (§ решение) и со сроком: миграция, которая её удаляет, войдёт после того, как переход устоится.

Публичная поверхность не изменилась. По-прежнему 12 маршрутов, и 26 engine-service по-прежнему 26 — route-surface.spec.ts закрылся с пустым diff, что было ожидаемым результатом и доказательством того, что контракт контроллеров не тронут.

Что было проверено и что нет​

Матрица RBAC доказывается тремя замками: decide.spec.ts (24 случая) и resolve-effective-role.use-case.spec.ts остались неизменными и зелёными; route-surface.spec.ts закрылся с пустым diff; и две новые спецификации — roles.guard.spec.ts, с матрицей 4×4, расписанной полностью, и jwt-auth.guard.spec.ts, утверждающая, что в users не появляется ни одной новой строки, — покрывают переход идентичность → user.id, у которого раньше не было ни одного теста и который является единственной точкой, затронутой переходом.

Набор тестов engine в этой поставке не запускался. Использованная среда разработки не достаёт до hex.pm (блокировка сетевой политикой), а без mix deps.get нет ни mix compile, ни mix test. Код Elixir написан и проверен синтаксическим анализом каждого изменённого файла; реальный запуск происходит в CI, где есть сеть. Это зафиксировано здесь, а не опущено, потому что это ровно тот тип пробела, который ADR 0020 велел никогда не диагностировать методом исключения.

Осознанный backlog (подтверждён)​

Вне рамок остаются, теперь уже после перехода: MFA (TOTP, WebAuthn), социальный вход, федерация OIDC и api как OIDC-провайдер. К ним добавляются: реальный SMTP, словарь утёкших паролей, оппортунистический перехеш argon2, очистка таблиц auth и миграция, удаляющая users.keycloak_sub.

Claim версии учётных данных — который сделал бы access token отзываемым — остаётся отмеченным с новой деталью: JwtAuthGuard в этом переходе перестал делать запись на каждый запрос, поэтому довод «он всё равно идёт в базу», который сделал бы её дешёвой, больше не действует. Если она вернётся на стол, то вернётся по собственным достоинствам.