OAuth 本身是 boilerplate,account linking 才是要命的。3 個 callback case、無密碼使用者、race condition、Turbo 吞 302、credentials 守衛——全在一篇。
給 how2claude 接 OAuth 這件事,真正難的不是 OAuth 本身。
裝 omniauth-google-oauth2 和 omniauth-github、配 initializer、寫 callback、加兩個按鈕——這部分是 10 分鐘的 boilerplate,任何 AI agent 都能做對。難的是 account linking:同一個信箱已經存在一個密碼帳戶,Google 登入來了要合併嗎?同一個使用者先用 Google 登入、後來又想綁 GitHub,binding 邏輯怎麼寫?兩個人同時第一次用 Google 登入同一個信箱,會不會建立兩份 User?
這篇講真實踩的坑。初始實作是在 Amp(91e4f48)裡做的——Amp 背後也是 Claude,互動體驗好適合快速鋪地基。幾天後 0112888 在 Claude Code 裡補了 Turbo 和 OAuth 按鈕的衝突修復。兩個工具自然銜接。
Amp 在 91e4f48 裡一次性交付了 boilerplate 部分:
Gemfile:omniauth、omniauth-google-oauth2、omniauth-githubOauthAccount 模型:provider / uid / email / name / avatar_url,unique index 在 [provider, uid] 上Auth::OmniauthController 的 callback action/auth/:provider/callback + /auth/failuresessions/new.html.erb 加了兩個 button_to(Google + GitHub)omniauth initializer 配 callback_pathUser 模型改了一行,這一行是核心(下面會講)。14 個檔案,257 行增量。
OAuth 機械部分到此結束。
使用者點 "Continue with Google" 之後,最終會回到你的 /auth/google_oauth2/callback。request.env["omniauth.auth"] 裡有 provider、uid、email、name、avatar。接下來需要判斷:
OauthAccount 行)→ 直接登入已綁定的 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 行看起來平平無奇,但每一個分支都藏一個坑。
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。
第 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 查不準。
修法:
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 寫高並行安全的資料庫操作,你要主動追問「這裡並行會怎麼樣?」。agent 預設假設單使用者序列,它寫的測試也只覆蓋序列路徑。
第 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 粗暴綁起來。這是刻意的簡化——等真出現問題再補。但你作為人要知道這裡有洞。
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 }。不是一個,是一類。
本地開發剛 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 就不顯示按鈕。這類小事值得加,因為:
讓 agent 一開始就寫這種「credentials 保護」守衛不太自動——它會假設設定都齊。feature 初版 + 補齊 credentials guard 是兩個階段,分兩次讓 agent 做。
這次工程分成兩個階段:
階段 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 登入,完整清單:
has_secure_password validations: false + 條件化長度校驗,別去掉 password 認證。button_to 加 data-turbo=false。跟 Stripe 是同一家族 bug。OAuth 本身不難。真正難的是承認邊界情況的存在——這些 agent 預設不會主動想,靠你作為人來問它 "並行怎麼樣?" "account 孤兒怎麼辦?" "本地沒 credentials 時按鈕點進去啥反應?"。
提出問題,讓 agent 寫答案。這是這套工作流真正的分工。