免费

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