ADR 0084 — Социальный вход (GitHub/GitLab), пересматривающий backlog ADR 0031/0032
Контекст
ADR 0031 (auth first-party) и ADR 0032 (отказ от Keycloak) явно поместили
социальный вход в осознанный backlog: «Keycloak предлагал их, и они
не использовались; реализовать их сейчас значило бы платить цену без
спроса». CLAUDE.md превратил это решение в постоянный запрет —
«Не реализовывать (…) социальный вход», — а docs/explanation/backlog.md
держал пункт открытым со времён ФАЗЫ 13c.
Владелец продукта явно запросил это направление, осознавая связанные с ним последствия для безопасности, и поэтому запрет был отменён ДЛЯ ЭТОГО НАПРАВЛЕНИЯ — не ретроактивно для MFA, OIDC provider или федерации, которые остаются вне охвата (см. Последствия).
В продукте уже есть ДВЕ части механизма для двух разных целей:
- Auth first-party (ADR 0031/0032):
EmitirSessaoUseCaseупаковывает пару access (Ed25519, короткий) + refresh (непрозрачный, ротация с семейством) после того, как идентичность уже определена, — используетсяLoginUseCaseиRegisterUseCaseсегодня. - OAuth GitHub/GitLab (Фаза 2, ADR 0059):
GitOauthClient(buildAuthorizeUrl/exchangeCode),GitOauthClientRegistry,signOauthState/verifyOauthState, подписанныеGIT_OAUTH_STATE_SECRET, — но для подключения учётных данных git к проекту УЖЕ АУТЕНТИФИЦИРОВАННОГО пользователя (StartGitOauthUseCaseтребуетprojectIdиuserId).
Работа этого направления — связать обе, НЕ выдумывая третий формат
сессии и не смешивая две цели state.
Решение
1. Механизм выпуска сессии используется повторно, без исключений
SocialLoginCallbackUseCase завершается вызовом ТОГО ЖЕ EmitirSessaoUseCase,
что используют LoginUseCase/RefreshUseCase. Нет второго формата
токена, cookie или claim для того, кто входит через GitHub/GitLab, — сессия того, кто
входит по паролю, и того, кто входит через OAuth, неразличимы после
выпуска.
2. OAuth-клиент используется повторно; цель state — НЕТ
GitOauthClient получил два новых метода —
buildLoginAuthorizeUrl/fetchIdentity, — реализованных ТЕМИ ЖЕ
GithubOauthClient/GitlabOauthClient потока подключения git.
exchangeCode используется повторно как есть.
То, что НЕ используется повторно, — это подпись state. domain/auth/social-oauth-state.ts
— это СОБСТВЕННЫЙ модуль, со структурно иным payload
({purpose: 'social_login', provider, nonce, expiresAt}, без projectId
и userId — для социального входа нет «где», есть только идентичность) и
дискриминантом ЦЕЛИ, проверяемым ДО любого другого поля
(RN-273). state из потока ПОДКЛЮЧЕНИЯ
git, даже подписанный ТЕМ ЖЕ ключом, здесь не принимается — и набор тестов доказывает
важное направление (state подключения git → верификатор социального входа):
принять такой state в callback ВХОДА значило бы войти как
userId другого человека — чистая эскалация привилегий.
HMAC-ключ остаётся GIT_OAUTH_STATE_SECRET
(resolveOauthStateSecret()) — никакой новой переменной окружения. Повторно использовать
ключ безопасно, потому что две цели разделяет структурная несовместимость payload,
а не секрет.
3. Минимальная область, собственная для входа
buildLoginAuthorizeUrl запрашивает read:user user:email (GitHub) и read_user
(GitLab) — никогда repo/api потока подключения. Вход в учётную запись не
должен предоставлять доступа ни к какому репозиторию
(RN-277).
4. Новая таблица: social_identities
Миграция 0047. Выделенный столбец в users был отвергнут по той причине, которой
уже учит keycloak_sub: один столбец на устаревший provider не масштабируется до
ДВУХ одновременных providers (пользователь может входить через GitHub и GitLab
одновременно). (provider, provider_user_id) уникальна; provider_user_id — это
ЧИСЛОВОЙ id provider, никогда не login/e-mail, — которые могут сменить владельца
(RN-276). user_id — NOT NULL: связь
рождается на ТОМ ЖЕ шаге, который определяет идентичность, без промежуточного состояния
«идентичность без владельца».
5. Три решения callback, по порядку
SocialLoginCallbackUseCase решает в таком порядке
(RN-272):
- Идентичность уже известна (
(provider, providerUserId)вsocial_identities) → прямой вход. - Новая идентичность, e-mail совпадает с существующей учётной записью И provider помечает
e-mail как ПОДТВЕРЖДЁННЫЙ → связывает и входит
(RN-274). Связывание — это слияние учётных записей:
подтверждение со стороны PROVIDER играет роль, которую клик по ссылке подтверждения
играет при регистрации по паролю, и поэтому, как побочный эффект, учётная запись,
зарегистрированная по паролю и ни разу не подтверждённая, получает заполненный
emailVerifiedAtпосле связывания (RN-279): provider только что доказал, независимым путём, ровно то, что доказал бы тот клик. - Новая идентичность, e-mail совпадает, но НЕ подтверждён → отказ
(
403). E-mail, просто набранный (не подтверждённый) в OAuth provider, не является доказательством владения — принять здесь значило бы открыть захват учётной записи: тот, у кого естьalguem@empresa.comв Brabo, не просил, чтобы чужой GitHub с этим адресом, лишь набранным, унаследовал учётную запись. - Новая идентичность, без соответствующей учётной записи → создаёт НОВОГО пользователя
без пароля — повторно используя ТО ЖЕ состояние «ожидания», которое миграция
Keycloak уже оставляет (
usersбез строки вauth_credentials, RN-278). Здесь e-mail НЕ обязан быть подтверждённым (RN-275): нет существующей учётной записи, которую можно захватить, есть только та, что рождается, а требование подтверждения удорожило бы обычный случай, ничего не защищая.LoginUseCaseиResetPasswordUseCaseуже умеют обрабатывать это состояние — социальная учётная запись получает «забыл пароль» бесплатно, без второго механизма.
6. Callback никогда не раскрывает токен в URL или теле
GET /auth/oauth/:provider/callback записывает cookies сессии
(definirCookiesDeSessao, ТА ЖЕ функция, что при входе по паролю) и перенаправляет
на WEB_ORIGIN/. access token не путешествует в URL: загрузка web
(restaurarSessao(), вызываемая при КАЖДОЙ загрузке страницы, apps/web/src/main.tsx)
уже обменивает только что записанный refresh на access token — ноль нового кода на
стороне клиента, кроме двух кнопок и alias маршрута ошибки
(RN-282). Сбой ведёт на
WEB_ORIGIN/login?oauth_error=1, без детализации причины — тот же шаблон, что у
callback подключения git (RN-283).
7. Повторное использование ТОГО ЖЕ OAuth-приложения — без новой переменной окружения
GITHUB_OAUTH_CLIENT_ID/_SECRET и GITLAB_OAUTH_CLIENT_ID/_SECRET
остаются теми, что уже зарегистрированы в приложении для подключения git. Что меняется по
потоку — это redirect_uri (/auth/oauth/<provider>/callback вместо
/git/oauth/<provider>/callback) и запрашиваемый scope — оба решаются во
время запроса, а не конфигурации
(RN-281). Действие оператора по-прежнему
необходимо: второй callback URL нужно зарегистрировать в OAuth-приложении
каждого provider (задокументировано в .env.example), — именно это требование, а не новая
env var, оправдывает рождение ветки как breaking/.
Последствия
- Два новых публичных маршрута (
GET /auth/oauth/:provider/start,GET /auth/oauth/:provider/callback), обоснованные вdocs/security-surface.mdи покрытыеroute-surface.spec.ts, — публичная поверхность растёт с двенадцати до четырнадцати маршрутов. social_identities— НОВАЯ таблица, без soft delete и без истории: отвязка идентичности (отзыв доступа через GitHub/GitLab с сохранением учётной записи) не имеет ни UI, ни маршрута в этом направлении — backlog.- Только GitHub и GitLab. Никакого общего OIDC provider, никакой федерации
SAML — backlog ADR 0031/0032 остаётся в силе для MFA,
общей федерации OIDC и api как OIDC provider. Этот ADR
пересматривает ТОЛЬКО пункт «социальный вход» из того списка, и только для двух
providers, у которых уже есть зарегистрированный
GitOauthClient. emailVerifiedзависит от того, говорит ли provider правду. GitHub разделяет e-mail и подтверждение на два вызова (/userи/user/emails); GitLab встраивает подтверждение вconfirmed_atУЧЁТНОЙ ЗАПИСИ. Оба рассматриваются как эквивалентные по продуктовому решению — ни один не проверяем со стороны Brabo сверх доверия ответу provider.- Учётная запись только с социальным входом никогда не проходит через корзину lockout входа по паролю (RN-030/031): нет попытки пароля, которую нужно сдерживать. Её сдерживает сам OAuth handshake на стороне provider.
CLAUDE.mdдолжен потерять фразу «Не реализовывать (…) социальный вход» из раздела «O que NÃO fazer» — не редактируется этим ADR (остаётся для PR закрытия волны, вместе с другими направлениями).