免費

讓 Claude 做 SaaS 登入:Google + GitHub OAuth + account linking

OAuth 本身是 boilerplate,account linking 才是要命的。3 個 callback case、無密碼使用者、race condition、Turbo 吞 302、credentials 守衛——全在一篇。


給 how2claude 接 OAuth 這件事,真正難的不是 OAuth 本身。

omniauth-google-oauth2omniauth-github、配 initializer、寫 callback、加兩個按鈕——這部分是 10 分鐘的 boilerplate,任何 AI agent 都能做對。難的是 account linking:同一個信箱已經存在一個密碼帳戶,Google 登入來了要合併嗎?同一個使用者先用 Google 登入、後來又想綁 GitHub,binding 邏輯怎麼寫?兩個人同時第一次用 Google 登入同一個信箱,會不會建立兩份 User?

這篇講真實踩的坑。初始實作是在 Amp91e4f48)裡做的——Amp 背後也是 Claude,互動體驗好適合快速鋪地基。幾天後 0112888Claude Code 裡補了 Turbo 和 OAuth 按鈕的衝突修復。兩個工具自然銜接。


10 分鐘的活

Amp 在 91e4f48 裡一次性交付了 boilerplate 部分:

  • Gemfileomniauthomniauth-google-oauth2omniauth-github
  • OauthAccount 模型:provider / uid / email / name / avatar_url,unique index 在 [provider, uid]
  • Auth::OmniauthController 的 callback action
  • 路由:/auth/:provider/callback + /auth/failure
  • sessions/new.html.erb 加了兩個 button_to(Google + GitHub)
  • 測試檔案:72 行的 controller tests,覆蓋 5 個場景
  • omniauth initializer 配 callback_path

User 模型改了一行,這一行是核心(下面會講)。14 個檔案,257 行增量。

OAuth 機械部分到此結束。

核心問題:callback 要處理 3 種情況

使用者點 "Continue with Google" 之後,最終會回到你的 /auth/google_oauth2/callbackrequest.env["omniauth.auth"] 裡有 provider、uid、email、name、avatar。接下來需要判斷:

  1. 這個 Google 帳號之前登入過嗎?(有對應 OauthAccount 行)→ 直接登入已綁定的 User
  2. 目前有人正登入著嗎?(session 裡有 user)→ 把 Google 綁到目前 User 下
  3. 都沒有?(Google 帳號首次,也沒人登入)→ 按信箱找現有 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 行看起來平平無奇,但每一個分支都藏一個坑。

難點 #1:OAuth-only 使用者沒密碼

Rails 8 預設的 has_secure_password 會校驗 password_digest 必填。用 Google 登入的新使用者沒有密碼——怎麼辦?

不要去掉 has_secure_password(那你密碼登入就廢了)。

:關掉預設校驗,手寫條件化校驗:

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: "",以後他想設密碼也行(滿足長度要求即可)。密碼登入使用者完全不受影響。

副作用:原有的 "password reset" 測試會掛——它可能用太短的密碼。Amp 一起改了 (test/controllers/passwords_controller_test.rb)。讓 agent 寫 feature 時順手看一眼測試影響面,省一次 re-run。

難點 #2:按信箱找使用者的 race condition

第 3 種情況裡這一行:

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

在單執行緒裡沒問題。正式環境下兩個請求幾乎同時打進來(極少但會發生)——兩個都 find_by 回傳 nil,兩個都 create!,一個成功,另一個掛在 email_address 的 unique index 上(假設你有)。

更糟:如果你沒有 email_address 上的 unique index,兩份同信箱 User 都建出來了,後續 Stripe customer 綁誰不確定、subscription 查不準。

修法:

  • DB 層放 unique index:add_index :users, :email_address, unique: true
  • 應用層用 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 寫高並行安全的資料庫操作,你要主動追問「這裡並行會怎麼樣?」。agent 預設假設單使用者序列,它寫的測試也只覆蓋序列路徑。

難點 #3:account binding 的歸屬問題

第 2 種情況——已登入使用者綁定新的 OAuth——看起來簡單,但有個邊界:

使用者 A 用信箱 [email protected] 登入,點 Continue with GitHub。GitHub 回呼帶回 [email protected] 這個信箱(A 的 GitHub 用的是工作信箱)。

現在系統裡可能存在:
- User A:email [email protected]
- User B:email [email protected](A 幾個月前用工作信箱另外註冊過)

如果你按目前邏輯 Current.user.oauth_accounts.create!(...),GitHub 會綁到 A 名下,但 B 這個帳戶還活著,裡面可能有 B 的 Purchase。B 再用密碼登入還在,但他的 GitHub 現在被 A 綁走了。

真正完整的 binding 邏輯要處理「對方帳戶的孤兒資料怎麼辦」——這不是 agent 預設會幫你想的東西。至少要 UI 提醒:「這個 GitHub 帳號關聯了信箱 [email protected],繼續會讓那個帳號無法再用 GitHub 登入」。

目前版本不管這個,只是把 Current.user 和 OAuth 粗暴綁起來。這是刻意的簡化——等真出現問題再補。但你作為人要知道這裡有洞。

踩坑 #1:Turbo 吞 Google 的 302

OAuth 接好上線,點 Continue with Google——頁面沒反應。Network tab 顯示 POST /auth/google_oauth2 → 302 到 accounts.google.com/o/oauth2/auth?...。瀏覽器沒跳。

這個 bug 前一篇 讓 Claude 接兩種支付:Stripe + x402 裡講過完全一樣的機理:button_to 生成的 <form method="post"> 被 Turbo 攔截,回應當 TURBO_STREAM 處理,而 TURBO_STREAM 不跟 cross-origin 302

修復 0112888 在 Claude Code 裡加的:

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

兩個 OAuth 按鈕都加。規律:任何 button_to 的目標會 302 到外部網域(Stripe Checkout、Stripe Portal、Google OAuth、GitHub OAuth、Apple Sign In…),都要 data: { turbo: false }。不是一個,是一類。

踩坑 #2:GitHub 按鈕在 credentials 沒配的時候也 render

本地開發剛 clone 專案、沒填 GitHub credentials——登入頁還是渲染 "Continue with GitHub"。點進去 Invalid client_id

同樣是 0112888 裡加的守衛:

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

沒配 GitHub credentials 就不顯示按鈕。這類小事值得加,因為:

  1. 開發者 clone 專案跑起來時不會看到壞按鈕
  2. 正式環境如果 credentials 意外遺失,按鈕至少不會顯示一個 500 按鈕讓人點
  3. 本地和正式 credentials 不同步時,功能降級而不是出錯

讓 agent 一開始就寫這種「credentials 保護」守衛不太自動——它會假設設定都齊。feature 初版 + 補齊 credentials guard 是兩個階段,分兩次讓 agent 做。

Amp + Claude Code 配合

這次工程分成兩個階段:

階段 1(Amp,91e4f48:一次會話鋪完 OAuth 全部 boilerplate——gem、模型、migration、controller、view、route、tests、i18n。14 個檔案 257 行,20 分鐘。Amp 的互動體驗適合這種「一次推進一大塊」的工作,thread 歷史記錄清晰,線性推進。

階段 2(Claude Code,0112888:幾天後上線前發現 Turbo + OAuth 的衝突。這時候做的是精確定位 + 1 行修復 + 迴歸測試。Claude Code 在專案目錄裡有完整的 git/session 上下文,加上這個專案的 hook(自動記錄 session 到 docs/notes/pro/raw.md,見 讓 Claude 寫 hook 記錄自己的 session),問題的診斷過程會留痕進我的文章素材。

兩個工具不衝突——Amp 適合從 0 鋪功能、長 thread 裡做大塊討論Claude Code 適合在已有專案裡做持續的增量工作 + 工程自動化。背後都是 Claude Opus 4.7(1M context),差別在互動模型和工具集。

讓同一個模型在兩種工具裡交替工作,就像同一個工程師有時候用 IDE 有時候用終端,各得其所。


讓 Claude(或 Amp)做 OAuth 登入,完整清單:

  1. 承認 OAuth 本身是 boilerplate,重點放 callback 的 3 個 case 上。
  2. OAuth-only 使用者用 has_secure_password validations: false + 條件化長度校驗,別去掉 password 認證。
  3. 按信箱找使用者那行加 DB 層 unique index + 應用層並行保護。agent 預設不會。
  4. Account binding 的跨使用者孤兒問題先用 UI 提醒,別裝作沒事
  5. 所有 OAuth button_todata-turbo=false。跟 Stripe 是同一家族 bug。
  6. credentials 沒配的 OAuth 按鈕不要 render——本地開發和正式雙重保險。
  7. 初版 feature 和 credentials guard 分兩次讓 agent 做。一次性要求齊活容易漏。

OAuth 本身不難。真正難的是承認邊界情況的存在——這些 agent 預設不會主動想,靠你作為人來問它 "並行怎麼樣?" "account 孤兒怎麼辦?" "本地沒 credentials 時按鈕點進去啥反應?"。

提出問題,讓 agent 寫答案。這是這套工作流真正的分工。