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

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 или федерации, которые остаются вне охвата (см. Последствия).

В продукте уже есть ДВЕ части механизма для двух разных целей:

  1. Auth first-party (ADR 0031/0032): EmitirSessaoUseCase упаковывает пару access (Ed25519, короткий) + refresh (непрозрачный, ротация с семейством) после того, как идентичность уже определена, — используется LoginUseCase и RegisterUseCase сегодня.
  2. 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):

  1. Идентичность уже известна ((provider, providerUserId) в social_identities) → прямой вход.
  2. Новая идентичность, e-mail совпадает с существующей учётной записью И provider помечает e-mail как ПОДТВЕРЖДЁННЫЙ → связывает и входит (RN-274). Связывание — это слияние учётных записей: подтверждение со стороны PROVIDER играет роль, которую клик по ссылке подтверждения играет при регистрации по паролю, и поэтому, как побочный эффект, учётная запись, зарегистрированная по паролю и ни разу не подтверждённая, получает заполненный emailVerifiedAt после связывания (RN-279): provider только что доказал, независимым путём, ровно то, что доказал бы тот клик.
  3. Новая идентичность, e-mail совпадает, но НЕ подтверждён → отказ (403). E-mail, просто набранный (не подтверждённый) в OAuth provider, не является доказательством владения — принять здесь значило бы открыть захват учётной записи: тот, у кого есть alguem@empresa.com в Brabo, не просил, чтобы чужой GitHub с этим адресом, лишь набранным, унаследовал учётную запись.
  4. Новая идентичность, без соответствующей учётной записи → создаёт НОВОГО пользователя без пароля — повторно используя ТО ЖЕ состояние «ожидания», которое миграция 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 закрытия волны, вместе с другими направлениями).