Free

Membiarkan Claude Membangun Login SaaS: Google + GitHub OAuth + account linking

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.


Bagian 10 menit

Amp nurunin boilerplate sekali jalan di 91e4f48:

  • Gemfile: omniauth, omniauth-google-oauth2, omniauth-github
  • Model OauthAccount: provider / uid / email / name / avatar_url, unique index di [provider, uid]
  • Action callback Auth::OmniauthController
  • Route: /auth/:provider/callback + /auth/failure
  • sessions/new.html.erb dengan dua button_to (Google + GitHub)
  • 72 baris controller tests yang cover 5 skenario
  • Initializer omniauth setting callback_path

Model User dapet perubahan satu baris — dan baris itu trik sebenernya (di bawah).

14 file, 257 baris tambahan. Bagian mekanis OAuth berakhir di sini.

Masalah sebenarnya: callback punya tiga kasus

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:

  1. Akun Google ini pernah login sebelumnya? (ada baris OauthAccount yang cocok) → login ke User yang udah ter-bind
  2. Lagi ada yang login sekarang? (session punya user) → bind Google ke User sekarang
  3. Gak dua-duanya? (akun Google baru, gak ada sesi) → cari by email atau bikin User baru

Kodenya:

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.

Jebakan #1: user OAuth-only gak punya password

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_password
  • validates :password, length:, if: password.present?: kalau user set password, paksa min 8; kalau gak, gak peduli

User 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.

Jebakan #2: race condition find-by-email

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:

  • Unique index level DB: add_index :users, :email_address, unique: true
  • Level app: pakai find_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.

Jebakan #3: masalah kepemilikan account binding

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.

Lubang #1: Turbo nelen 302 Google

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.

Lubang #2: tombol GitHub render tanpa credentials

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:

  1. Developer yang clone project gak liat tombol rusak
  2. Kalau credentials gak sengaja ilang di production, gak ada tombol rusak buat diklik
  3. Pas credentials local dan prod drift, feature turun kualitas bukan error

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.

Amp + Claude Code: dua tool, satu model

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:

  1. Akuin OAuth sendiri itu boilerplate. Fokus ke tiga kasus callback.
  2. User OAuth-only: has_secure_password validations: false + panjang kondisional. Jangan copot auth password.
  3. Tambah unique index DB di email + guard konkurensi level app buat find-by-email. Agent gak bakal.
  4. Masalah yatim cross-user account binding: warning UI minimal. Jangan pura-pura gak ada.
  5. Setiap button_to OAuth butuh data-turbo=false. Keluarga bug yang sama dengan Stripe.
  6. Gak ada credentials → jangan render tombol OAuth. Cover drift dev dan prod.
  7. Scaffold feature dan credentials guard dua lintasan. Jangan minta dalam satu prompt.

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.