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 写答案。这是这套工作流真正的分工。