Free

Claude'a SaaS girişi yaptırmak: Google + GitHub OAuth + account linking

OAuth boilerplate'dir; asıl ısıran şey account linking. Callback'in üç durumu, şifresiz kullanıcılar, race condition'lar, Turbo'nun 302'yi yutması, credentials koruması — hepsi tek yazıda.


how2claude'a OAuth eklemek, zor olan OAuth'un kendisi değildi.

omniauth-google-oauth2 ve omniauth-github kurmak, initializer yazmak, callback yazmak, iki buton eklemek — hepsi 10 dakikalık boilerplate, herhangi bir AI agent bunu doğru yapar. Zor olan account linking: bir e-posta zaten şifreli hesaba sahipse, Google girişi geldiğinde birleştirir mi? Kullanıcının zaten Google bağlıysa ve GitHub da eklemek istiyorsa, binding mantığı nasıl? İki kişi aynı e-postayla eşzamanlı ilk Google girişi yaparsa iki User mı çıkar?

Bu gerçek hikaye. İlk uygulama Amp'te (91e4f48) yapıldı — Amp arkada Claude'u kullanıyor, büyük bir temel serme işine çok uygun etkileşim modeli. Birkaç gün sonra, 0112888 Claude Code'da Turbo-vs-OAuth çatışmasını yamaladı. İki araç birbirine doğal geçiyor.


10 dakikalık kısım

Amp, 91e4f48'te boilerplate'i tek seferde indirdi:

  • Gemfile: omniauth, omniauth-google-oauth2, omniauth-github
  • OauthAccount modeli: provider / uid / email / name / avatar_url, [provider, uid] üzerinde unique index
  • Auth::OmniauthController callback action
  • Route'lar: /auth/:provider/callback + /auth/failure
  • sessions/new.html.erb iki button_to ile (Google + GitHub)
  • 5 senaryoyu kapsayan 72 satır controller test
  • callback_path yapılandırmalı omniauth initializer

User modeli tek satırlık bir değişiklik aldı — ve o tek satır asıl numara (aşağıda).

14 dosya, 257 satır eklendi. OAuth'un mekanik kısmı burada biter.

Asıl problem: callback üç duruma bakar

Kullanıcı "Continue with Google" tıkladığında, sonunda /auth/google_oauth2/callback'e iner. request.env["omniauth.auth"] provider, uid, email, name, avatar tutar. Karar vermen gerek:

  1. Bu Google hesabı daha önce giriş yaptı mı? (eşleşen OauthAccount satırı) → zaten bağlı User ile giriş yap
  2. Şu an biri oturum açmış mı? (session'da user var) → Google'ı mevcut User'a bağla
  3. İkisi de değil mi? (Google hesabı yeni, session yok) → e-postayla ara ya da yeni User oluştur

Kod:

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

Sıradan görünen kırk altı satır. Her dal bir tuzak saklıyor.

Tuzak #1: OAuth-only kullanıcıların şifresi yok

Rails 8'in default has_secure_password'u password_digest zorunlu kılar. Google ile giren bir kullanıcının şifresi yok — ne yapmalı?

Yapma has_secure_password'u çıkarma (şifre girişi bozulur).

Yap: default validasyonu kapat, koşullu validasyonu kendin yaz:

class User < ApplicationRecord
  has_secure_password validations: false
  validates :password, length: { minimum: 8 }, if: -> { password.present? }
  # ...
end
  • validations: false: has_secure_password ile gelen "şifre zorunlu" kuralını kaldırır
  • validates :password, length:, if: password.present?: kullanıcı şifre koyarsa min 8 zorla; koymazsa umursama

OAuth-only kullanıcılar password_digest: "" ile oluşturulur. Sonradan şifre ekleyebilirler (8+ karakter olmak kaydıyla). Şifreli auth kullanıcıları hiç etkilenmez.

Yan etki: mevcut "password reset" testi bozuldu — çok kısa şifre kullanıyordu. Amp aynı commit'te düzeltti (test/controllers/passwords_controller_test.rb). Agent feature eklerken test yüzey alanına bir göz at — bir re-run döngüsü kazandırır.

Tuzak #2: find-by-email'in race condition'ı

Callback'in üçüncü dalı:

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

Tek thread'de sorunsuz. Production'da iki istek neredeyse eşzamanlı gelir (nadir ama olur) — ikisi de find_by nil döner, ikisi de create!, biri başarılı olur, diğeri email_address üzerindeki unique index'e çarpar (var olduğunu varsayarsak).

Daha kötüsü: email_address üzerinde unique index yoksa, aynı e-postalı iki User oluşturulur. Aşağı akışta: Stripe customer bağlaması belirsiz, subscription lookup'ları yanlış.

Düzeltme:

  • DB seviyesi unique index: add_index :users, :email_address, unique: true
  • App seviyesi: find_or_create_by! kullan ya da rescue ile sar:
user = User.find_by(email_address: auth.info.email) ||
       User.create_with(password_digest: "").find_or_create_by!(email_address: auth.info.email)

Amp'in orijinal versiyonu sarmadı. Bir agent yüksek concurrency güvenli DB kodu yazdığında, aktif olarak "burada concurrency ne olur?" diye sormalısın. Agent'lar default olarak tek kullanıcı serial erişimi varsayar — testleri de sadece serial yolları kapsar.

Tuzak #3: account binding'in sahiplik problemi

İkinci dal — oturum açmış kullanıcının yeni OAuth provider bağlaması — basit görünür ama kötü bir sınır durumu var.

Kullanıcı A [email protected] ile giriş yapmış. Continue with GitHub'a tıklıyor. GitHub callback'i [email protected] döndürüyor (A'nın GitHub'ı iş e-postası kullanıyor).

Şimdi veritabanı içerebilir:
- User A: email [email protected]
- User B: email [email protected] (A aylar önce iş e-postasıyla ayrı kayıt olmuş)

Kod Current.user.oauth_accounts.create!(...) çalıştırıldığında, GitHub A'ya bağlanır. Ama User B hâlâ var, muhtemelen B'nin Purchase'ları var. B hâlâ şifreyle girebilir ama GitHub'ı artık A'nın.

Eksiksiz bir binding akışı "karşı hesabın yetim verisine ne yapıyoruz?" sorusunu ele almalı — agent'lar default olarak bunu düşünmez. Asgari olarak UI uyarısı: "Bu GitHub hesabı [email protected] e-postasına bağlı. Devam etmek o hesabın GitHub ile giriş yapmasını önleyecek."

Mevcut versiyon bunu ele almıyor — sadece Current.user'ı OAuth kaydına bağlayıp devam ediyor. Bu kasıtlı basitleştirme: problem gerçekten çıkarsa yamala. Ama sen insan olarak deliğin var olduğunu bilmelisin.

Çukur #1: Turbo Google'ın 302'sini yutuyor

OAuth bağlandı, deploy edildi, Continue with Google'a tıkla — hiçbir şey olmuyor. Network sekmesi POST /auth/google_oauth2 → 302 accounts.google.com/o/oauth2/auth?... gösteriyor. Tarayıcı kıpırdamıyor.

Önceki yazı Claude'a iki ödeme entegrasyonu yaptırmak: Stripe + x402 aynı mekanizmayı kapsadı: button_to <form method="post"> üretir, Turbo form'u yakalar, yanıtı TURBO_STREAM olarak işler, ve TURBO_STREAM cross-origin 302'leri takip etmez.

Düzeltme 0112888'te indirdi (Claude Code'da yapıldı):

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

Her iki OAuth butonuna gerekli. Kural: hedefi harici domain'e 302 yapan her button_to (Stripe Checkout, Stripe Portal, Google OAuth, GitHub OAuth, Apple Sign-In…) data: { turbo: false } ister. Bir buton değil — bir sınıf.

Çukur #2: GitHub butonu credentials yoksa da render oluyor

Taze klonlanmış repo, GitHub credentials yapılandırılmamış — login sayfası yine de "Continue with GitHub" render ediyor. Tıklamak Invalid client_id veriyor.

Aynı commit (0112888) bir guard ekledi:

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

GitHub credentials yok → buton yok. Yapmaya değer çünkü:

  1. Projeyi klonlayan dev'ler bozuk buton görmez
  2. Credentials production'da kazara düşerse, tıklanacak bozuk buton olmaz
  3. Local ve prod credentials ayrıldığında, özellik hata vermek yerine düşer

Bir agent'a başta bu tür "credentials guard" yazdırmak otomatik gelmez — config'in her zaman dolu olduğunu varsayar. Feature scaffold + credentials guard iki geçiştir. Ayır, tek prompt'ta isteme.

Amp + Claude Code: iki araç, tek model

OAuth işi iki faza bölündü:

Faz 1 (Amp, 91e4f48): bir oturumda tüm OAuth levhasını yerleştirme — gems, model, migration, controller, view, route'lar, tests, i18n. 14 dosya / 257 satır / 20 dakika. Amp'in etkileşim modeli "büyük parçayı bir seferde ilerletme"ye uyuyor, temiz doğrusal thread geçmişi.

Faz 2 (Claude Code, 0112888): günler sonra canlıya geçmeden önce Turbo-vs-OAuth bizi yakaladı. Burası hassas teşhis + tek satır düzeltme + regression test alanı. Claude Code proje dizini içinde tam git/session context'e sahip, artı projenin kendi session kayıt hook'u (her şeyi otomatik olarak docs/notes/pro/raw.md'ye yakalar — bkz Claude'a kendi session'ını kaydeden hook'ları yazdırmak). Debug izi yazı malzemesine dönüşüyor.

İki araç çarpışmıyor. Amp sıfırdan scaffolding ve uzun tartışma thread'leri için; Claude Code mevcut projede artımlı iş ve mühendislik otomasyonu için. Altta aynı model (Claude Opus 4.7, 1M context) — farklı etkileşim biçimleri ve toolset'ler.

Aynı mühendis, bazen IDE'de, bazen terminalde. Her biri farklı anlara uyuyor.


Claude'a (ya da Amp'e) OAuth girişi yaptırmak — tam çek listesi:

  1. OAuth'un kendisinin boilerplate olduğunu kabul et. Callback'in üç durumuna odaklan.
  2. OAuth-only kullanıcılar: has_secure_password validations: false + koşullu uzunluk. Şifre auth'u kaldırma.
  3. E-posta üzerinde DB unique index + find-by-email için app seviyesi concurrency guard ekle. Agent yapmaz.
  4. Account binding'in cross-user yetim problemi: asgari UI uyarısı. Yokmuş gibi davranma.
  5. Her OAuth button_to'ya data-turbo=false gerekli. Stripe ile aynı bug ailesi.
  6. Credentials yok → OAuth butonunu render etme. Dev ve prod drift'i kapsar.
  7. Feature scaffold ve credentials guard iki geçiş. Tek prompt'ta isteme.

OAuth'un kendisi zor değil. Zor olan, agent'ların doğal olarak yüzeye çıkarmadığı sınır durumlarını kabul etmek — concurrency, yetim veri, yerel ortamda eksik credentials. İnsan olarak rolün o soruları sormak: "Concurrency nasıl? Account yetimleri ne olacak? Credentials yoksa ne olur?"

Sen soruyorsun, agent cevapları yazıyor. Bu workflow'daki gerçek iş bölümü bu.