OAuth — це boilerplate; кусається саме зв'язування акаунтів. Три випадки callback, безпарольні користувачі, race condition, Turbo ковтає 302, захист credentials — усе в одній статті.
Під'єднати OAuth до how2claude було складно не через OAuth.
Встановити omniauth-google-oauth2 і omniauth-github, написати initializer, написати callback, додати дві кнопки — це 10 хвилин boilerplate, будь-який AI-агент впорається. Складне — зв'язування акаунтів: якщо 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 використовує робочу пошту).
Тепер БД може містити:
- User A: email [email protected]
- User B: email [email protected] (A зареєструвався з робочою поштою місяці тому)
Якщо виконати 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.