Free

ให้ Claude สร้างล็อกอิน SaaS: Google + GitHub OAuth + account linking

OAuth เป็น boilerplate; account linking คือตัวที่กัด สาม case callback, ผู้ใช้ไม่มีรหัส, race condition, Turbo กลืน 302, ป้องกัน credentials — ทั้งหมดในบทความเดียว


ต่อ OAuth เข้า how2claude ไม่ได้ยากเพราะ OAuth เอง

ติดตั้ง omniauth-google-oauth2 และ omniauth-github เขียน initializer เขียน callback เพิ่มปุ่มสองปุ่ม — นี่คือ 10 นาทีของ boilerplate ที่ AI agent ไหนก็ทำถูก สิ่งที่ยากคือ account linking: ถ้าอีเมลมีบัญชีรหัสผ่านอยู่แล้ว login Google มาจะรวมกันไหม? ถ้าผู้ใช้เชื่อม Google แล้วอยากเพิ่ม GitHub logic binding ทำอย่างไร? ถ้าคนสองคนแย่งกัน login Google ครั้งแรกด้วยอีเมลเดียวกัน จะได้ User สองคนไหม?

นี่คือเรื่องจริง การ implement ตอนแรกทำใน Amp (91e4f48) — Amp เบื้องหลังก็ Claude โมเดลปฏิสัมพันธ์เหมาะกับการวางฐานใหญ่ในครั้งเดียว หลายวันต่อมา 0112888 ใน Claude Code แก้ความขัดแย้ง Turbo-กับ-OAuth สองเครื่องมือส่งไม้ต่อกันเป็นธรรมชาติ


ส่วน 10 นาที

Amp ลงจอด boilerplate ในครั้งเดียวที่ 91e4f48:

  • Gemfile: omniauth, omniauth-google-oauth2, omniauth-github
  • โมเดล OauthAccount: provider / uid / email / name / avatar_url พร้อม unique index บน [provider, uid]
  • action callback ของ Auth::OmniauthController
  • route: /auth/:provider/callback + /auth/failure
  • sessions/new.html.erb กับ button_to สองปุ่ม (Google + GitHub)
  • 72 บรรทัด controller test ครอบคลุม 5 สถานการณ์
  • initializer omniauth ตั้ง callback_path

โมเดล User ได้รับการเปลี่ยนบรรทัดเดียว — บรรทัดนั้นคือเคล็ดจริง (ด้านล่าง)

14 ไฟล์ 257 บรรทัดเพิ่ม ส่วน เชิงกลไก ของ OAuth จบตรงนี้

ปัญหาจริง: callback มีสามกรณี

เมื่อผู้ใช้คลิก "Continue with Google" สุดท้ายจะมาที่ /auth/google_oauth2/callback request.env["omniauth.auth"] มี provider, uid, email, name, avatar ต้องตัดสินใจ:

  1. บัญชี Google นี้เคย login หรือไม่? (มีแถว OauthAccount ตรงกัน) → login User ที่ผูกไว้
  2. ตอนนี้มีคน login อยู่หรือไม่? (session มี user) → ผูก Google กับ User ปัจจุบัน
  3. ไม่ใช่ทั้งสอง? (บัญชี Google ใหม่ ไม่มี session) → หาด้วย email หรือสร้าง 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

46 บรรทัดที่ดูธรรมดา แต่ละ branch ซ่อนกับดัก

กับดัก #1: ผู้ใช้ OAuth-only ไม่มีรหัสผ่าน

has_secure_password ค่าเริ่มต้นของ Rails 8 ต้องการ password_digest ผู้ใช้ที่ login ด้วย Google ไม่มีรหัสผ่าน — ทำยังไง?

อย่า เอา has_secure_password ออก (ทำให้ login รหัสผ่านพัง)

ต้องทำ: ปิด validation ค่าเริ่มต้น เขียน validation แบบมีเงื่อนไขเอง:

class User < ApplicationRecord
  has_secure_password validations: false
  validates :password, length: { minimum: 8 }, if: -> { password.present? }
  # ...
end
  • validations: false: เอาออกกฎ "รหัสผ่านจำเป็น" ที่มากับ has_secure_password
  • validates :password, length:, if: password.present?: ถ้า ผู้ใช้ตั้งรหัสผ่าน บังคับความยาวขั้นต่ำ 8; ถ้าไม่ตั้งก็ไม่สน

ผู้ใช้ OAuth-only สร้างด้วย password_digest: "" ภายหลังสามารถเพิ่มรหัสผ่านได้ (ตราบเท่า 8+ ตัว) ผู้ใช้ auth รหัสผ่านไม่ได้รับผลกระทบเลย

ผลข้างเคียง: test "password reset" ที่มีอยู่พัง — ใช้รหัสผ่านสั้นเกินไป Amp แก้ใน commit เดียวกัน (test/controllers/passwords_controller_test.rb) พอ agent เพิ่มฟีเจอร์ ลองมองพื้นผิวของ test — ประหยัดรอบ re-run หนึ่งครั้ง

กับดัก #2: race condition ของ find-by-email

branch ที่สามของ callback:

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

ใน single-thread ปลอดภัย ใน production request สองตัวมาถึงเกือบพร้อมกัน (หายาก แต่เกิดขึ้น) — ทั้งสอง find_by คืน nil ทั้งสอง create! หนึ่งสำเร็จ อีกหนึ่งชน unique index บน email_address (สมมติว่ามี)

แย่กว่า: ถ้า ไม่มี unique index บน email_address User อีเมลเดียวกันถูกสร้างสองคน ต่อจากนั้น: การผูก Stripe customer คลุมเครือ lookup subscription ผิด

แก้:

  • unique index ระดับ DB: add_index :users, :email_address, unique: true
  • ระดับ app: ใช้ find_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 ไม่ห่อ พอ agent เขียนโค้ด DB concurrency สูง ต้องถามเชิงรุกว่า "ตรงนี้ concurrency เป็นอย่างไร?" agent ตั้งสมมติฐานเริ่มต้นว่าเข้าถึงเป็นลำดับของผู้ใช้คนเดียว — test ของมันครอบคลุมเฉพาะ path แบบลำดับ

กับดัก #3: ปัญหาเด็กกำพร้าของ account binding

branch ที่สอง — ผู้ใช้ login ผูก OAuth ใหม่ — ดูง่าย แต่มี edge case ที่แย่

ผู้ใช้ A login ด้วย [email protected] คลิก Continue with GitHub callback GitHub คืน [email protected] (GitHub ของ A ใช้อีเมลที่ทำงาน)

ตอนนี้ DB อาจมี:
- User A: email [email protected]
- User B: email [email protected] (A เคยสมัครด้วยอีเมลที่ทำงานเมื่อหลายเดือนก่อน)

ถ้ารัน Current.user.oauth_accounts.create!(...) อย่างที่โค้ดทำ GitHub ผูกเข้ากับ A แต่ User B ยังอยู่ อาจมี Purchase ของ B B ยัง login รหัสผ่านได้ แต่ GitHub ตอนนี้ของ A

flow binding ที่สมบูรณ์ต้องจัดการ "ข้อมูลเด็กกำพร้าของบัญชีอีกฝ่ายทำอย่างไร?" — agent ไม่คิดเรื่องนี้โดยอัตโนมัติ อย่างน้อยคำเตือน UI: "บัญชี GitHub นี้เชื่อมกับอีเมล [email protected] การดำเนินต่อจะทำให้บัญชีนั้นไม่สามารถใช้ GitHub login ได้"

เวอร์ชันปัจจุบันไม่จัดการเรื่องนี้ — แค่ผูก Current.user กับ record OAuth อย่างหยาบแล้วไปต่อ นี่คือ การทำให้ง่ายโดยเจตนา: ปะเมื่อปัญหาเกิดขึ้นจริง แต่คุณในฐานะมนุษย์ต้องรู้ว่ารูมีอยู่

หลุม #1: Turbo กลืน 302 ของ Google

OAuth ต่อแล้ว deploy แล้ว คลิก Continue with Google — ไม่มีอะไรเกิดขึ้น tab network แสดง POST /auth/google_oauth2 → 302 ไป accounts.google.com/o/oauth2/auth?... browser ไม่ขยับ

บทความก่อนหน้า ให้ Claude เชื่อมต่อสองช่องทางจ่ายเงิน: Stripe + x402 ครอบคลุมกลไกเดียวกันเป๊ะ: button_to สร้าง <form method="post"> Turbo ดัก form ตัดสินใจ response เป็น TURBO_STREAM และ TURBO_STREAM ไม่ตาม 302 cross-origin

fix ลงจอดใน 0112888 (ทำใน Claude Code):

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

ปุ่ม OAuth ทั้งสองต้องการ กฎ: button_to ไหนก็ตามที่เป้าหมาย 302 ไปยัง domain ภายนอก (Stripe Checkout, Stripe Portal, Google OAuth, GitHub OAuth, Apple Sign-In…) ต้องการ data: { turbo: false } ไม่ใช่ปุ่มเดียว — ทั้งกลุ่ม

หลุม #2: ปุ่ม GitHub render โดยไม่มี credentials

repo clone มาใหม่ credentials GitHub ยังไม่ได้ตั้ง — หน้า login ยังคง render "Continue with GitHub" คลิกแล้วได้ Invalid client_id

commit เดียวกัน (0112888) เพิ่ม guard:

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

ไม่มี credentials GitHub → ไม่มีปุ่ม คุ้มค่าทำเพราะ:

  1. dev ที่ clone project ไม่เห็นปุ่มเสีย
  2. ถ้า credentials หลุดโดยบังเอิญใน production ไม่มีปุ่มเสียให้คลิก
  3. เมื่อ credentials ในเครื่องและ prod ไม่ตรงกัน ฟีเจอร์ degrades แทนที่จะ error

การให้ agent เขียน "credentials guard" แบบนี้ตั้งแต่แรกไม่มาอัตโนมัติ — มันสมมติว่า config เต็มเสมอ scaffold ฟีเจอร์ + credentials guard เป็นสองเที่ยว แยกกัน อย่าขอในคำสั่งเดียว

Amp + Claude Code: สองเครื่องมือ หนึ่งโมเดล

งาน OAuth แยกเป็นสองช่วง:

ช่วง 1 (Amp, 91e4f48): session เดียววางแผ่น OAuth ทั้งแผ่น — gems, โมเดล, migration, controller, view, routes, tests, i18n 14 ไฟล์ / 257 บรรทัด / 20 นาที โมเดลปฏิสัมพันธ์ของ Amp เข้ากับ "ดันก้อนใหญ่ทีละก้อน" ด้วยประวัติ thread ที่เป็นเส้นตรงสะอาด

ช่วง 2 (Claude Code, 0112888): หลายวันต่อมา ก่อนขึ้น production Turbo-กับ-OAuth จับเรา นี่คือพื้นที่ของการวินิจฉัยแม่นยำ + fix บรรทัดเดียว + regression test Claude Code มี context git/session ครบใน directory project บวก hook บันทึก session ของ project เอง (capture ทุกอย่างอัตโนมัติไป docs/notes/pro/raw.md — ดู ให้ Claude เขียน hook บันทึก session ของตัวเอง) ร่องรอย debug กลายเป็นวัตถุดิบบทความ

สองเครื่องมือไม่ชนกัน Amp สำหรับ scaffolding จากศูนย์และ thread ยาวสำหรับอภิปรายเปิด; Claude Code สำหรับงาน incremental ใน project ที่มีอยู่และอัตโนมัติเชิงวิศวกรรม โมเดลเดียวกันข้างใต้ (Claude Opus 4.7, 1M context) — รูปแบบปฏิสัมพันธ์และชุดเครื่องมือต่างกัน

วิศวกรคนเดียวกัน บางครั้งใน IDE บางครั้งใน terminal แต่ละอันเหมาะกับช่วงเวลาต่างกัน


ให้ Claude (หรือ Amp) สร้าง OAuth login — checklist เต็ม:

  1. ยอมรับว่า OAuth เองเป็น boilerplate โฟกัสที่สาม case ของ callback
  2. ผู้ใช้ OAuth-only: has_secure_password validations: false + ความยาวแบบมีเงื่อนไข อย่าทิ้ง auth รหัสผ่าน
  3. เพิ่ม unique index DB บน email + guard concurrency ระดับ app สำหรับ find-by-email agent ไม่ทำ
  4. ปัญหา cross-user กำพร้าของ account binding: คำเตือน UI อย่างน้อย อย่าแกล้งทำเป็นไม่มี
  5. ทุก button_to OAuth ต้องการ data-turbo=false ตระกูล bug เดียวกับ Stripe
  6. ไม่มี credentials → อย่า render ปุ่ม OAuth ครอบคลุม drift dev และ prod
  7. scaffold ฟีเจอร์ และ credentials guard สองเที่ยว อย่าขอในคำสั่งเดียว

OAuth เองไม่ยาก ที่ยากคือยอมรับ edge case ที่ agent ไม่เปิดเผยโดยธรรมชาติ — concurrency ข้อมูลกำพร้า credentials ขาดในสภาพแวดล้อมท้องถิ่น บทบาทคุณในฐานะมนุษย์คือถามคำถามเหล่านั้น: "concurrency ล่ะ? กำพร้าบัญชีล่ะ? ถ้า credentials ไม่ได้ตั้งล่ะ?"

คุณถามคำถาม agent เขียนคำตอบ นั่นคือการแบ่งงานจริงใน workflow นี้