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 สองเครื่องมือส่งไม้ต่อกันเป็นธรรมชาติ
Amp ลงจอด boilerplate ในครั้งเดียวที่ 91e4f48:
Gemfile: omniauth, omniauth-google-oauth2, omniauth-githubOauthAccount: provider / uid / email / name / avatar_url พร้อม unique index บน [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 ตรงกัน) → login 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 ซ่อนกับดัก
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_passwordvalidates :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 หนึ่งครั้ง
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 ผิด
แก้:
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 ไม่ห่อ พอ agent เขียนโค้ด DB concurrency สูง ต้องถามเชิงรุกว่า "ตรงนี้ concurrency เป็นอย่างไร?" agent ตั้งสมมติฐานเริ่มต้นว่าเข้าถึงเป็นลำดับของผู้ใช้คนเดียว — test ของมันครอบคลุมเฉพาะ path แบบลำดับ
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 อย่างหยาบแล้วไปต่อ นี่คือ การทำให้ง่ายโดยเจตนา: ปะเมื่อปัญหาเกิดขึ้นจริง แต่คุณในฐานะมนุษย์ต้องรู้ว่ารูมีอยู่
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 } ไม่ใช่ปุ่มเดียว — ทั้งกลุ่ม
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 → ไม่มีปุ่ม คุ้มค่าทำเพราะ:
การให้ agent เขียน "credentials guard" แบบนี้ตั้งแต่แรกไม่มาอัตโนมัติ — มันสมมติว่า config เต็มเสมอ scaffold ฟีเจอร์ + credentials guard เป็นสองเที่ยว แยกกัน อย่าขอในคำสั่งเดียว
งาน 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 เต็ม:
has_secure_password validations: false + ความยาวแบบมีเงื่อนไข อย่าทิ้ง auth รหัสผ่านbutton_to OAuth ต้องการ data-turbo=false ตระกูล bug เดียวกับ StripeOAuth เองไม่ยาก ที่ยากคือยอมรับ edge case ที่ agent ไม่เปิดเผยโดยธรรมชาติ — concurrency ข้อมูลกำพร้า credentials ขาดในสภาพแวดล้อมท้องถิ่น บทบาทคุณในฐานะมนุษย์คือถามคำถามเหล่านั้น: "concurrency ล่ะ? กำพร้าบัญชีล่ะ? ถ้า credentials ไม่ได้ตั้งล่ะ?"
คุณถามคำถาม agent เขียนคำตอบ นั่นคือการแบ่งงานจริงใน workflow นี้