OAuth itu boilerplate; account linking baru yang nyusahin. Tiga kasus callback, user tanpa password, race condition, Turbo nelan 302, jaga credentials — semua dalam satu artikel.
Nyolokin OAuth ke how2claude, yang susah bukan OAuth-nya.
Install omniauth-google-oauth2 dan omniauth-github, tulis initializer, tulis callback, tambah dua tombol — itu tumpukan boilerplate 10 menit yang AI agent manapun kerjain benar. Yang susah itu account linking: kalau email udah ada akun password, login Google datang — gabungin? Kalau user udah punya Google dan mau tambah GitHub, logic binding gimana? Kalau dua orang balapan login Google pertama dengan email sama, akhirnya dua User?
Ini cerita beneran. Implementasi awal dikerjain di Amp (91e4f48) — Amp di baliknya juga Claude, model interaksinya cocok buat naruh fondasi besar sekaligus. Beberapa hari kemudian, 0112888 di Claude Code nge-patch konflik Turbo-vs-OAuth. Dua tool yang oper tongkat secara alami.
Amp nurunin boilerplate sekali jalan di 91e4f48:
Gemfile: omniauth, omniauth-google-oauth2, omniauth-githubOauthAccount: provider / uid / email / name / avatar_url, unique index di [provider, uid]Auth::OmniauthController/auth/:provider/callback + /auth/failuresessions/new.html.erb dengan dua button_to (Google + GitHub)omniauth setting callback_pathModel User dapet perubahan satu baris — dan baris itu trik sebenernya (di bawah).
14 file, 257 baris tambahan. Bagian mekanis OAuth berakhir di sini.
Pas user klik "Continue with Google", akhirnya dia mendarat di /auth/google_oauth2/callback. request.env["omniauth.auth"] punya provider, uid, email, name, avatar. Harus putusin:
OauthAccount yang cocok) → login ke User yang udah ter-bindKodenya:
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
Empat puluh enam baris yang keliatan biasa aja. Tiap branch nyembunyin jebakan.
has_secure_password default Rails 8 minta password_digest. User login pake Google gak punya password — jadi gimana?
Jangan copot has_secure_password (rusakin login password).
Harus: matiin validasi default, tulis validasi kondisional sendiri:
class User < ApplicationRecord
has_secure_password validations: false
validates :password, length: { minimum: 8 }, if: -> { password.present? }
# ...
end
validations: false: buang aturan "password wajib" yang ikutan sama has_secure_passwordvalidates :password, length:, if: password.present?: kalau user set password, paksa min 8; kalau gak, gak peduliUser OAuth-only dibuat dengan password_digest: "". Nanti bisa tambah password (asal 8+ char). User auth password gak kena pengaruh sama sekali.
Efek samping: test "password reset" yang udah ada rusak — pakai password terlalu pendek. Amp betulin di commit yang sama (test/controllers/passwords_controller_test.rb). Pas agent nambah feature, liat sekilas area permukaan test — hemat satu siklus re-run.
Branch ketiga callback:
user = User.find_by(email_address: auth.info.email) || User.create!(...)
Aman single-thread. Di production, dua request nyampe hampir bareng (jarang tapi kejadian) — dua-dua find_by balikin nil, dua-dua create!, satu sukses, satu lagi nabrak unique index email_address (asumsi kamu punya).
Lebih buruk: kalau gak punya unique index di email_address, dua User email sama dibuat. Ke bawahnya: binding Stripe customer ambigu, lookup subscription salah.
Fix:
add_index :users, :email_address, unique: truefind_or_create_by! atau bungkus dengan rescue:user = User.find_by(email_address: auth.info.email) ||
User.create_with(password_digest: "").find_or_create_by!(email_address: auth.info.email)
Versi asli Amp gak bungkus. Pas agent nulis kode DB konkurensi tinggi, kamu harus aktif nanya "gimana kalau konkuren?". Agent default asumsi akses serial satu user — testnya cuma cover path serial.
Branch kedua — user login bind OAuth provider baru — keliatan simpel tapi ada kasus batas jelek.
User A login pake [email protected]. Klik Continue with GitHub. Callback GitHub balikin [email protected] (GitHub A pake email kerjaan).
Sekarang database bisa isi:
- User A: email [email protected]
- User B: email [email protected] (A daftar pake email kerjaan beberapa bulan lalu)
Kalau jalanin Current.user.oauth_accounts.create!(...) kayak kode lakuin, GitHub ter-bind ke A. Tapi User B masih hidup, mungkin punya Purchase B. B masih bisa login password, tapi GitHubnya sekarang punya A.
Flow binding yang lengkap harus handle "data yatim akun lawan itu diapain?" — agent gak mikirin ini secara default. Minimal, warning UI: "Akun GitHub ini terhubung ke email [email protected]. Lanjut bikin akun itu gak bisa login pake GitHub lagi."
Versi sekarang gak handle ini — cuma bind Current.user ke record OAuth dan lanjut. Ini penyederhanaan disengaja: nanti patch kalau masalahnya beneran muncul. Tapi kamu sebagai manusia harus tau lubangnya ada.
OAuth nyambung, deploy, klik Continue with Google — gak ada apa-apa. Tab network nunjukin POST /auth/google_oauth2 → 302 ke accounts.google.com/o/oauth2/auth?.... Browser gak gerak.
Artikel sebelumnya Membiarkan Claude Mengintegrasikan Dua Cara Pembayaran: Stripe + x402 cover mekanisme yang persis sama: button_to generate <form method="post">, Turbo intercept formnya, perlakukan response sebagai TURBO_STREAM, dan TURBO_STREAM gak ngikutin 302 cross-origin.
Fix mendarat di 0112888 (dikerjain di Claude Code):
<%= button_to "/auth/google_oauth2", method: :post,
+ form: { data: { turbo: false } },
class: "..." do %>
Continue with Google
<% end %>
Dua tombol OAuth butuh. Aturan: button_to manapun yang targetnya 302 ke domain eksternal (Stripe Checkout, Stripe Portal, Google OAuth, GitHub OAuth, Apple Sign-In…) butuh data: { turbo: false }. Bukan satu tombol — satu kelas.
Repo baru di-clone, credentials GitHub belum di-set — halaman login tetap render "Continue with GitHub." Klik kasih Invalid client_id.
Commit yang sama (0112888) nambah guard:
<% if Rails.application.credentials.dig(:github, :client_id).present? %>
<%= button_to "/auth/github", ... %>
<% end %>
Tanpa credentials GitHub → tanpa tombol. Worth dilakuin karena:
Nyuruh agent nulis "credentials guard" kayak gini dari awal gak otomatis — dia asumsi config selalu penuh. Scaffold feature + credentials guard itu dua lintasan. Pisahin, jangan minta dalam satu prompt.
Kerjaan OAuth kebagi jadi dua fase:
Fase 1 (Amp, 91e4f48): satu sesi naruh seluruh lempeng OAuth — gems, model, migration, controller, view, route, tests, i18n. 14 file / 257 baris / 20 menit. Model interaksi Amp cocok buat "dorong potongan besar sekaligus", dengan histori thread yang linear dan rapi.
Fase 2 (Claude Code, 0112888): beberapa hari kemudian sebelum live, Turbo-vs-OAuth kena kita. Ini wilayah diagnosa presisi + fix satu baris + test regresi. Claude Code punya konteks git/session lengkap di dalam direktori project, plus hook project sendiri buat rekam sesi (otomatis capture semuanya ke docs/notes/pro/raw.md — lihat Membiarkan Claude Menulis Hook yang Merekam Dirinya Sendiri). Jejak debug jadi materi artikel.
Dua tool gak bentrok. Amp buat scaffolding dari nol dan thread panjang diskusi terbuka; Claude Code buat kerjaan inkremental di project existing dan otomasi engineering. Model di baliknya sama (Claude Opus 4.7, 1M context) — bentuk interaksi dan toolset beda.
Engineer yang sama, kadang di IDE, kadang di terminal. Masing-masing cocok di momen berbeda.
Biarin Claude (atau Amp) bangun login OAuth — checklist lengkap:
has_secure_password validations: false + panjang kondisional. Jangan copot auth password.button_to OAuth butuh data-turbo=false. Keluarga bug yang sama dengan Stripe.OAuth sendiri gak susah. Yang susah itu ngakuin kasus batas yang agent gak muncul natural — konkurensi, data yatim, credentials ilang di env lokal. Peran kamu sebagai manusia itu nanya pertanyaannya: "Gimana konkurensi? Gimana yatim akun? Gimana kalau credentials gak diset?"
Kamu yang nanya, agent yang nulis jawaban. Itu pembagian kerja sebenarnya di workflow ini.