Free

Để Claude xây đăng nhập SaaS: Google + GitHub OAuth + account linking

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


Phần 10 phút

Amp hạ cánh phần boilerplate trong một phát ở 91e4f48:

  • Gemfile: omniauth, omniauth-google-oauth2, omniauth-github
  • Model OauthAccount: provider / uid / email / name / avatar_url, unique index trên [provider, uid]
  • Action callback Auth::OmniauthController
  • Routes: /auth/:provider/callback + /auth/failure
  • sessions/new.html.erb với hai button_to (Google + GitHub)
  • 72 dòng controller tests phủ 5 kịch bản
  • Initializer omniauth cấu hình callback_path

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

Vấn đề thật: callback xử lý ba ca

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:

  1. Tài khoản Google này đã đăng nhập trước đó chưa? (có dòng OauthAccount khớp) → đăng nhập User đã gắn
  2. Hiện có ai đang đăng nhập không? (session có user) → gắn Google vào User hiện tại
  3. Cả hai đều không? (Google mới toanh, chưa có session) → tìm theo email hoặc tạo User mới

Code:

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.

Bẫy #1: user OAuth-only không có mật khẩu

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_password
  • validates :password, length:, if: password.present?: nếu user đặt mật khẩu, bắt min 8; không đặt thì thôi

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

Bẫy #2: race condition của find-by-email

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:

  • Unique index cấp DB: add_index :users, :email_address, unique: true
  • Cấp app: dùng find_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.

Bẫy #3: vấn đề sở hữu của account binding

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.

Hố #1: Turbo nuốt 302 của Google

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.

Hố #2: nút GitHub render dù không có credentials

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ì:

  1. Dev clone project về không thấy nút hỏng
  2. Nếu credentials vô tình biến mất ở production, không có nút hỏng để bấm
  3. Khi credentials local và prod trôi lệch, feature hạ cấp thay vì lỗi

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.

Amp + Claude Code: hai công cụ, một model

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 đủ:

  1. Thừa nhận OAuth tự nó là boilerplate. Tập trung vào ba ca của callback.
  2. User OAuth-only: has_secure_password validations: false + chiều dài có điều kiện. Đừng bỏ auth mật khẩu.
  3. Thêm unique index DB trên email + guard concurrency cấp app cho find-by-email. Agent sẽ không làm.
  4. Vấn đề mồ côi cross-user của account binding: cảnh báo UI tối thiểu. Đừng giả vờ không có.
  5. Mỗi button_to OAuth cần data-turbo=false. Cùng họ bug với Stripe.
  6. Không credentials → đừng render nút OAuth. Phủ drift dev lẫn prod.
  7. Scaffold feature và credentials guard là hai lượt. Đừng đòi trong một prompt.

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.