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. Два инструмента, естественно передающих друг другу эстафету.
Amp уложил boilerplate одним махом в 91e4f48:
Gemfile: omniauth, omniauth-google-oauth2, omniauth-githubOauthAccount: provider / uid / email / name / avatar_url, с уникальным индексом на [provider, uid]Auth::OmniauthController/auth/:provider/callback + /auth/failuresessions/new.html.erb с двумя button_to (Google + GitHub)omniauth настраивает callback_pathМодель User получила изменение в одну строку — и эта строка и есть реальный трюк (ниже).
14 файлов, 257 строк добавлено. Механическая часть OAuth заканчивается здесь.
Когда пользователь кликает "Continue with Google", он в итоге попадает на /auth/google_oauth2/callback. request.env["omniauth.auth"] хранит provider, uid, email, name, avatar. Нужно решить:
OauthAccount) → войти уже связанному 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
Сорок шесть строк, выглядящих обыденно. Каждая ветвь скрывает ловушку.
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_passwordvalidates :password, length:, if: password.present?: если пользователь задаёт пароль, требуй минимум 8; иначе неважноOAuth-only пользователи создаются с password_digest: "". Они могут добавить пароль позже (если 8+ символов). Пользователи с парольной auth вообще не затрагиваются.
Побочный эффект: существующий тест "password reset" сломался — использовал слишком короткий пароль. Amp починил в том же коммите (test/controllers/passwords_controller_test.rb). Когда агент добавляет фичу, бегло глянь на поверхность тестов — сэкономишь один цикл re-run.
Третья ветвь 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 работает неправильно.
Фикс:
add_index :users, :email_address, unique: truefind_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-код с высокой конкурентностью, нужно активно спрашивать "что здесь происходит при конкурентности?". Агенты по умолчанию предполагают последовательный доступ одного пользователя — их тесты покрывают только последовательные пути.
Вторая ветвь — залогиненный пользователь привязывает нового 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-записи и идёт дальше. Это намеренное упрощение: патчить, когда проблема реально возникнет. Но ты как человек должен знать, что дырка есть.
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 }. Не одна кнопка — целый класс.
Свежеклонированное репо, 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 → нет кнопки. Стоит делать, потому что:
Заставить агента писать такую "защиту credentials" сразу не получится автоматически — он предполагает, что config всегда заполнен. Scaffold фичи + защита credentials — два прохода. Разделяй, не проси в одном промпте.
Работа 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-логин — полный чеклист:
has_secure_password validations: false + условная длина. Не убирай парольную auth.button_to нужен data-turbo=false. Та же семья багов, что и Stripe.Сам OAuth не труден. Трудно — признать граничные случаи, которые агенты не всплывают естественно: конкурентность, сиротские данные, отсутствующие credentials в локальной среде. Твоя роль как человека — задать эти вопросы: "Как насчёт конкурентности? Как насчёт сирот аккаунта? Что если credentials не заданы?"
Ты задаёшь вопросы, агент пишет ответы. Это и есть реальное разделение труда в этом workflow.