Free

Пусть Claude делает SaaS-логин: Google + GitHub OAuth и связывание аккаунтов

OAuth — это boilerplate; кусает именно связывание аккаунтов. Три случая callback, беспарольные пользователи, race condition, Turbo глотает 302, защита credentials — всё в одной статье.


Подключить OAuth в how2claude оказалось сложно не из-за OAuth.

Установить omniauth-google-oauth2 и omniauth-github, написать initializer, написать callback, добавить две кнопки — это 10 минут boilerplate, с этим справится любой ИИ-агент. Сложное — связывание аккаунтов: если email уже имеет парольный аккаунт, вход через Google должен объединить? Если у пользователя уже привязан Google и он хочет добавить GitHub, как работает binding? Если два человека одновременно впервые логинятся через Google с одним email, получим ли мы двух User?

Это настоящая история. Первоначальная реализация была сделана в Amp (91e4f48) — Amp под капотом работает на Claude, с моделью взаимодействия, хорошо подходящей для укладки большой базы за один раз. Через несколько дней 0112888 в Claude Code пропатчил конфликт Turbo-vs-OAuth. Два инструмента, естественно передающих друг другу эстафету.


Часть на 10 минут

Amp уложил boilerplate одним махом в 91e4f48:

  • Gemfile: omniauth, omniauth-google-oauth2, omniauth-github
  • Модель OauthAccount: provider / uid / email / name / avatar_url, с уникальным индексом на [provider, uid]
  • Callback-экшн в Auth::OmniauthController
  • Маршруты: /auth/:provider/callback + /auth/failure
  • sessions/new.html.erb с двумя button_to (Google + GitHub)
  • 72 строки controller-тестов, покрывающих 5 сценариев
  • Initializer omniauth настраивает callback_path

Модель User получила изменение в одну строку — и эта строка и есть реальный трюк (ниже).

14 файлов, 257 строк добавлено. Механическая часть OAuth заканчивается здесь.

Настоящая проблема: callback имеет три случая

Когда пользователь кликает "Continue with Google", он в итоге попадает на /auth/google_oauth2/callback. request.env["omniauth.auth"] хранит provider, uid, email, name, avatar. Нужно решить:

  1. Этот Google-аккаунт уже заходил раньше? (есть соответствующая строка OauthAccount) → войти уже связанному User
  2. Сейчас кто-то залогинен? (в сессии есть user) → привязать Google к текущему User
  3. Ни то, ни другое? (Google-аккаунт новый, сессии нет) → найти по email или создать нового User

Код:

class Auth::OmniauthController < ApplicationController
  allow_unauthenticated_access only: [:callback, :failure]
  skip_forgery_protection only: :callback

  def callback
    auth = request.env["omniauth.auth"]
    oauth_account = OauthAccount.find_by(provider: auth.provider, uid: auth.uid)

    if oauth_account
      start_new_session_for(oauth_account.user)
    elsif (resume_session; Current.user)
      Current.user.oauth_accounts.create!(oauth_params(auth))
      redirect_to root_path, notice: I18n.t("auth.oauth_linked", provider: auth.provider.titleize) and return
    else
      user = User.find_by(email_address: auth.info.email) || User.create!(
        email_address: auth.info.email,
        password_digest: ""
      )
      user.oauth_accounts.create!(oauth_params(auth))
      start_new_session_for(user)
    end

    redirect_to after_authentication_url
  end

  private

  def oauth_params(auth)
    {
      provider: auth.provider,
      uid: auth.uid,
      email: auth.info.email,
      name: auth.info.name,
      avatar_url: auth.info.image
    }
  end
end

Сорок шесть строк, выглядящих обыденно. Каждая ветвь скрывает ловушку.

Ловушка #1: OAuth-only пользователи без пароля

has_secure_password по умолчанию в Rails 8 требует password_digest. У пользователя, заходящего через Google, нет пароля — и что?

Не убирай has_secure_password (сломается парольный логин).

Надо: отключить дефолтную валидацию и написать свою условную:

class User < ApplicationRecord
  has_secure_password validations: false
  validates :password, length: { minimum: 8 }, if: -> { password.present? }
  # ...
end
  • validations: false: убирает правило "пароль обязателен", идущее с has_secure_password
  • validates :password, length:, if: password.present?: если пользователь задаёт пароль, требуй минимум 8; иначе неважно

OAuth-only пользователи создаются с password_digest: "". Они могут добавить пароль позже (если 8+ символов). Пользователи с парольной auth вообще не затрагиваются.

Побочный эффект: существующий тест "password reset" сломался — использовал слишком короткий пароль. Amp починил в том же коммите (test/controllers/passwords_controller_test.rb). Когда агент добавляет фичу, бегло глянь на поверхность тестов — сэкономишь один цикл re-run.

Ловушка #2: race condition у find-by-email

Третья ветвь callback:

user = User.find_by(email_address: auth.info.email) || User.create!(...)

Нормально в single-thread. В проде два запроса приходят почти одновременно (редко, но бывает) — оба find_by возвращают nil, оба create!, один успевает, другой бьётся об уникальный индекс на email_address (если он есть).

Хуже: если уникального индекса на email_address нет, создаются два User с одним email. Дальше: привязка Stripe customer неоднозначна, поиск subscription работает неправильно.

Фикс:

  • Уникальный индекс на уровне DB: add_index :users, :email_address, unique: true
  • На уровне app: использовать find_or_create_by! или обернуть rescue:
user = User.find_by(email_address: auth.info.email) ||
       User.create_with(password_digest: "").find_or_create_by!(email_address: auth.info.email)

Оригинальная версия Amp не оборачивала. Когда агент пишет DB-код с высокой конкурентностью, нужно активно спрашивать "что здесь происходит при конкурентности?". Агенты по умолчанию предполагают последовательный доступ одного пользователя — их тесты покрывают только последовательные пути.

Ловушка #3: проблема сиротства при binding аккаунта

Вторая ветвь — залогиненный пользователь привязывает нового OAuth-провайдера — кажется простой, но имеет неприятный граничный случай.

Пользователь A залогинен как [email protected]. Кликает Continue with GitHub. Callback GitHub возвращает [email protected] (GitHub A использует рабочий email).

Теперь БД может содержать:
- User A: email [email protected]
- User B: email [email protected] (A зарегистрировался с рабочим email несколько месяцев назад)

Если запустить Current.user.oauth_accounts.create!(...) как делает код, GitHub привязывается к A. Но User B всё ещё существует, возможно с покупками B. B всё ещё может логиниться паролем, но его GitHub теперь у A.

Полный flow binding должен обрабатывать "что делаем с сиротскими данными чужого аккаунта?" — агенты об этом не думают по умолчанию. Как минимум, UI-предупреждение: "Этот GitHub-аккаунт привязан к email [email protected]. Продолжение не позволит тому аккаунту использовать GitHub для входа."

Текущая версия не обрабатывает это — просто грубо привязывает Current.user к OAuth-записи и идёт дальше. Это намеренное упрощение: патчить, когда проблема реально возникнет. Но ты как человек должен знать, что дырка есть.

Западня #1: Turbo глотает 302 от Google

OAuth подключён, задеплоен, клик Continue with Google — ничего не происходит. Вкладка network показывает POST /auth/google_oauth2 → 302 на accounts.google.com/o/oauth2/auth?.... Браузер не двигается.

Предыдущая статья Пусть Claude интегрирует два способа оплаты: Stripe + x402 описала ровно такой же механизм: button_to генерирует <form method="post">, Turbo перехватывает форму, обрабатывает ответ как TURBO_STREAM, а TURBO_STREAM не следует за cross-origin 302.

Фикс приземлился в 0112888 (сделан в Claude Code):

 <%= button_to "/auth/google_oauth2", method: :post,
+      form: { data: { turbo: false } },
       class: "..." do %>
   Continue with Google
 <% end %>

Обе OAuth-кнопки его нуждаются. Правило: любой button_to, цель которого делает 302 на внешний домен (Stripe Checkout, Stripe Portal, Google OAuth, GitHub OAuth, Apple Sign-In…), нуждается в data: { turbo: false }. Не одна кнопка — целый класс.

Западня #2: кнопка GitHub рендерится без credentials

Свежеклонированное репо, credentials GitHub не сконфигурированы — страница логина всё равно рендерит "Continue with GitHub". Клик даёт Invalid client_id.

Тот же коммит (0112888) добавил охрану:

<% if Rails.application.credentials.dig(:github, :client_id).present? %>
  <%= button_to "/auth/github", ... %>
<% end %>

Нет credentials GitHub → нет кнопки. Стоит делать, потому что:

  1. Разработчики, клонирующие проект, не видят сломанную кнопку
  2. Если credentials случайно пропадут в проде, не будет сломанной кнопки, чтобы нажать
  3. Когда credentials local и prod расходятся, фича деградирует вместо ошибки

Заставить агента писать такую "защиту credentials" сразу не получится автоматически — он предполагает, что config всегда заполнен. Scaffold фичи + защита credentials — два прохода. Разделяй, не проси в одном промпте.

Amp + Claude Code: два инструмента, одна модель

Работа OAuth разделилась на две фазы:

Фаза 1 (Amp, 91e4f48): одна сессия, укладывающая всю плиту OAuth — gems, модель, миграция, controller, view, маршруты, тесты, i18n. 14 файлов / 257 строк / 20 минут. Модель взаимодействия Amp подходит для "продвижения большого куска за раз" с чистой линейной историей thread.

Фаза 2 (Claude Code, 0112888): дни спустя, перед выкаткой, Turbo-vs-OAuth нас поймал. Территория точной диагностики + однострочного фикса + регрессионного теста. У Claude Code полный git/session-контекст внутри каталога проекта, плюс собственный hook записи сессии проекта (автоматически захватывает всё в docs/notes/pro/raw.md — см. Пусть Claude пишет хуки, которые записывают его самого). След отладки становится материалом статьи.

Эти два инструмента не конфликтуют. Amp — для scaffolding с нуля и длинных threads открытой дискуссии; Claude Code — для инкрементной работы внутри существующего проекта и инженерной автоматизации. Под капотом одна модель (Claude Opus 4.7, 1M контекст) — разные формы взаимодействия и наборы инструментов.

Один и тот же инженер, иногда в IDE, иногда в терминале. Каждый подходит для разных моментов.


Пусть Claude (или Amp) строит OAuth-логин — полный чеклист:

  1. Признать, что сам OAuth — boilerplate. Фокус на трёх случаях callback.
  2. OAuth-only пользователи: has_secure_password validations: false + условная длина. Не убирай парольную auth.
  3. Добавь уникальный индекс DB на email + защиту конкурентности на уровне app для find-by-email. Агент не будет.
  4. Проблема cross-user сиротства при binding аккаунта: UI-предупреждение минимум. Не делай вид, что её нет.
  5. Каждому OAuth button_to нужен data-turbo=false. Та же семья багов, что и Stripe.
  6. Нет credentials → не рендерить OAuth-кнопку. Покрывает drift dev и prod.
  7. Scaffold фичи и защита credentials — два прохода. Не проси в одном промпте.

Сам OAuth не труден. Трудно — признать граничные случаи, которые агенты не всплывают естественно: конкурентность, сиротские данные, отсутствующие credentials в локальной среде. Твоя роль как человека — задать эти вопросы: "Как насчёт конкурентности? Как насчёт сирот аккаунта? Что если credentials не заданы?"

Ты задаёшь вопросы, агент пишет ответы. Это и есть реальное разделение труда в этом workflow.