OAuth là boilerplate; account linking mới là thứ cắn. Ba case callback, user không mật khẩu, race condition, Turbo nuốt 302, bảo vệ credentials — tất cả trong một bài.
Gắn OAuth vào how2claude, cái khó không phải là OAuth.
Cài omniauth-google-oauth2 và omniauth-github, viết initializer, viết callback, thêm hai nút — đó là 10 phút boilerplate, AI agent nào cũng làm đúng. Khó là account linking: email đã có tài khoản mật khẩu rồi, đăng nhập Google đến thì gộp không? Người dùng đã liên kết Google rồi lại muốn thêm GitHub thì logic binding ra sao? Hai người cùng lúc lần đầu đăng nhập Google với cùng email có ra hai User không?
Đây là câu chuyện thật. Phần triển khai ban đầu làm trong Amp (91e4f48) — Amp chạy trên Claude bên dưới, mô hình tương tác phù hợp với việc lát một mảng nền lớn cùng một lúc. Mấy ngày sau, 0112888 trong Claude Code vá xung đột Turbo-vs-OAuth. Hai công cụ chuyền tay nhau rất tự nhiên.
Amp hạ cánh phần boilerplate trong một phát ở 91e4f48:
Gemfile: omniauth, omniauth-google-oauth2, omniauth-githubOauthAccount: provider / uid / email / name / avatar_url, unique index trên [provider, uid]Auth::OmniauthController/auth/:provider/callback + /auth/failuresessions/new.html.erb với hai button_to (Google + GitHub)omniauth cấu hình callback_pathModel User đổi một dòng — và dòng đó mới là mánh thật sự (bên dưới).
14 file, 257 dòng thêm. Phần cơ học của OAuth kết thúc ở đây.
Khi người dùng bấm "Continue with Google", cuối cùng họ rơi vào /auth/google_oauth2/callback. request.env["omniauth.auth"] chứa provider, uid, email, name, avatar. Bạn cần quyết định:
OauthAccount khớp) → đăng nhập User đã gắnCode:
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
Bốn mươi sáu dòng trông bình thường. Mỗi nhánh giấu một cái bẫy.
has_secure_password mặc định của Rails 8 yêu cầu password_digest. User đăng nhập bằng Google không có mật khẩu — làm sao?
Đừng gỡ has_secure_password (sẽ phá đăng nhập mật khẩu).
Hãy tắt validation mặc định và viết validation có điều kiện tự tay:
class User < ApplicationRecord
has_secure_password validations: false
validates :password, length: { minimum: 8 }, if: -> { password.present? }
# ...
end
validations: false: bỏ quy tắc "mật khẩu bắt buộc" đi kèm has_secure_passwordvalidates :password, length:, if: password.present?: nếu user đặt mật khẩu, bắt min 8; không đặt thì thôiUser OAuth-only tạo với password_digest: "". Sau này có thể đặt mật khẩu (miễn 8+ ký tự). User auth mật khẩu hoàn toàn không ảnh hưởng.
Tác dụng phụ: test "password reset" hiện có hỏng — dùng mật khẩu quá ngắn. Amp sửa trong cùng commit (test/controllers/passwords_controller_test.rb). Khi agent thêm feature, lướt qua diện tích test — tiết kiệm một vòng re-run.
Nhánh thứ ba của callback:
user = User.find_by(email_address: auth.info.email) || User.create!(...)
Ổn trong single-thread. Trong production, hai request đến gần như đồng thời (hiếm nhưng có) — cả hai find_by trả về nil, cả hai create!, một thành công, cái kia đập vào unique index trên email_address (giả sử bạn có).
Tệ hơn: nếu không có unique index trên email_address, hai User cùng email được tạo. Xuôi dòng: binding Stripe customer mơ hồ, lookup subscription sai.
Sửa:
add_index :users, :email_address, unique: truefind_or_create_by! hoặc bọc rescue:user = User.find_by(email_address: auth.info.email) ||
User.create_with(password_digest: "").find_or_create_by!(email_address: auth.info.email)
Bản gốc của Amp không bọc. Khi agent viết code DB concurrency cao, bạn phải chủ động hỏi "ở đây concurrency thế nào?". Agent mặc định giả định truy cập serial một user — test của nó cũng chỉ phủ đường serial.
Nhánh thứ hai — user đã đăng nhập gắn OAuth provider mới — trông đơn giản nhưng có biên xấu.
User A đăng nhập bằng [email protected]. Bấm Continue with GitHub. Callback GitHub trả về [email protected] (GitHub của A dùng email công việc).
Giờ database có thể chứa:
- User A: email [email protected]
- User B: email [email protected] (A đã đăng ký bằng email công việc vài tháng trước)
Nếu chạy Current.user.oauth_accounts.create!(...) như code làm, GitHub gắn vào A. Nhưng User B vẫn sống, có thể có Purchase của B. B vẫn đăng nhập mật khẩu được, nhưng GitHub giờ của A.
Flow binding đầy đủ phải xử lý "dữ liệu mồ côi của tài khoản kia làm sao?" — agent không nghĩ đến cái này mặc định. Tối thiểu, cảnh báo UI: "Tài khoản GitHub này liên kết với email [email protected]. Tiếp tục sẽ khiến tài khoản đó không còn đăng nhập bằng GitHub được."
Phiên bản hiện tại không xử lý — chỉ gắn Current.user vào record OAuth và đi tiếp. Đây là đơn giản hóa có chủ ý: vá khi vấn đề thực sự xuất hiện. Nhưng bạn với tư cách con người cần biết lỗ hổng tồn tại.
OAuth cắm xong, deploy, bấm Continue with Google — không có gì xảy ra. Tab network hiện POST /auth/google_oauth2 → 302 đến accounts.google.com/o/oauth2/auth?.... Trình duyệt không nhúc nhích.
Bài trước Để Claude tích hợp hai cổng thanh toán: Stripe + x402 đã viết chính xác cơ chế này: button_to sinh <form method="post">, Turbo chặn form, xử lý response là TURBO_STREAM, và TURBO_STREAM không theo 302 cross-origin.
Fix hạ cánh ở 0112888 (làm trong Claude Code):
<%= button_to "/auth/google_oauth2", method: :post,
+ form: { data: { turbo: false } },
class: "..." do %>
Continue with Google
<% end %>
Cả hai nút OAuth đều cần. Luật: bất kỳ button_to nào mà đích 302 sang domain ngoài (Stripe Checkout, Stripe Portal, Google OAuth, GitHub OAuth, Apple Sign-In…), cần data: { turbo: false }. Không phải một nút — một lớp.
Repo vừa clone, chưa cấu hình credentials GitHub — trang đăng nhập vẫn render "Continue with GitHub". Bấm vào ra Invalid client_id.
Cùng commit (0112888) thêm guard:
<% if Rails.application.credentials.dig(:github, :client_id).present? %>
<%= button_to "/auth/github", ... %>
<% end %>
Không credentials GitHub → không nút. Đáng làm vì:
Bắt agent viết "credentials guard" kiểu này từ đầu không tự động — nó giả định config luôn đầy. Scaffold feature + credentials guard là hai lượt. Tách ra, đừng đòi trong một prompt.
Công việc OAuth chia hai pha:
Pha 1 (Amp, 91e4f48): một session lát toàn bộ nền OAuth — gems, model, migration, controller, view, routes, tests, i18n. 14 file / 257 dòng / 20 phút. Mô hình tương tác của Amp hợp với "đẩy một mảng lớn một lượt", lịch sử thread tuyến tính và sạch.
Pha 2 (Claude Code, 0112888): mấy ngày sau, trước lúc lên live, Turbo-vs-OAuth tóm được bọn mình. Đây là địa hạt của chẩn đoán chính xác + fix một dòng + regression test. Claude Code có context git/session đầy đủ bên trong thư mục project, cộng hook ghi session của chính project này (tự động capture mọi thứ vào docs/notes/pro/raw.md — xem Để Claude viết hook tự ghi lại session của chính mình). Vết debug trở thành tư liệu bài viết.
Hai công cụ không xung đột. Amp dành cho scaffolding từ số 0 và thread dài thảo luận mở; Claude Code dành cho công việc tăng dần trong project đang có và tự động hóa kỹ thuật. Cùng model bên dưới (Claude Opus 4.7, 1M context) — hình thức tương tác và toolset khác nhau.
Cùng một kỹ sư, khi trong IDE, khi trong terminal. Mỗi nơi hợp với thời điểm riêng.
Để Claude (hoặc Amp) xây login OAuth — checklist đầy đủ:
has_secure_password validations: false + chiều dài có điều kiện. Đừng bỏ auth mật khẩu.button_to OAuth cần data-turbo=false. Cùng họ bug với Stripe.OAuth tự nó không khó. Khó là thừa nhận các ca biên mà agent không tự nhiên nêu — concurrency, dữ liệu mồ côi, credentials thiếu trong môi trường local. Vai trò của bạn với tư cách con người là đặt các câu hỏi đó: "Concurrency thế nào? Mồ côi tài khoản thế nào? Không có credentials thì sao?"
Bạn đặt câu hỏi, agent viết câu trả lời. Đó là phân công thật sự trong workflow này.