Free

Claude に SaaS ログインを作らせる:Google + GitHub OAuth と account linking

OAuth 自体は boilerplate、刺さるのは account linking。callback の 3 ケース、パスワードなしユーザー、race condition、Turbo が 302 を飲む、credentials ガード——全部ここに。


how2claude に OAuth を組み込む、本当に難しかったのは OAuth そのものじゃない。

omniauth-google-oauth2omniauth-github を入れて、initializer を書いて、callback を書いて、ボタンを 2 つ追加する——これは 10 分の boilerplate、どの AI agent でも正しくこなす。難しいのは account linking:既存のメールでパスワードアカウントがある場合、Google ログインで統合するか?Google を先に繋げたユーザーが後から GitHub を追加したい場合、binding ロジックはどう書く?2 人が同じメールで同時に初 Google ログインしたら User が 2 個できないか?

実践の痛みを書く。初期実装は Amp91e4f48)で行った——Amp の裏は Claude、大きな土台を一気に敷き詰めるのに適した対話モデル。数日後、0112888Claude Code 側から Turbo と OAuth の衝突を修正した。2 つのツールが自然に連携する。


10 分の仕事

Amp が 91e4f48 で boilerplate 部分を一発で仕上げた:

  • Gemfileomniauthomniauth-google-oauth2omniauth-github
  • OauthAccount モデル:provider / uid / email / name / avatar_url[provider, uid] に unique index
  • Auth::OmniauthController の callback アクション
  • ルート:/auth/:provider/callback + /auth/failure
  • sessions/new.html.erb に button_to 2 つ(Google + GitHub)
  • テストファイル:72 行の controller tests、5 シナリオをカバー
  • omniauth initializer で callback_path を設定

User モデルは 1 行だけ変更、その 1 行がコア(後述)。14 ファイル、257 行の増分。

OAuth の機械的な部分はここで終わり。

コア問題:callback は 3 ケースを扱う

ユーザーが "Continue with Google" をクリックすると、最終的に /auth/google_oauth2/callback に戻ってくる。request.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: falsehas_secure_password 付属の「パスワード必須」ルールを外す
  • validates :password, length:, if: password.present?もしユーザーがパスワードを設定したら、最小 8 文字を強制;設定しなければ無視

OAuth-only ユーザーは password_digest: "" で作成される。後からパスワードを設定することも可能(8 文字以上であれば)。パスワード認証ユーザーには影響なし。

副作用:既存の "password reset" テストが壊れる——短すぎるパスワードを使っていた。Amp は同じコミットで修正した(test/controllers/passwords_controller_test.rb)。agent に機能を書かせるとき、テストへの影響範囲をさっと確認しておくと、re-run サイクルが 1 回減る。

罠 #2:メールでユーザーを探すときの race condition

第 3 ケースのこの行:

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

シングルスレッドでは問題ない。本番では 2 つのリクエストがほぼ同時に届く(稀だが起きる)——両方の find_by が nil を返し、両方が create!、片方は成功、もう片方は email_address の unique index(ある前提)に引っかかる。

もっと悪い:email_address に unique index がない場合、同じメールの User が 2 つ作られる。以降:Stripe customer 紐付けが曖昧、subscription lookup が正しくない。

対策:

  • 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 に高並行安全の DB 操作を書かせる場合、「ここの並行性どうなる?」と能動的に問う必要がある。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 の下に紐付く。でも User 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 タブは POST /auth/google_oauth2 → 302 accounts.google.com/o/oauth2/auth?...。ブラウザは動かない。

前の記事 Claude に 2 種類の決済を統合させる:Stripe + x402 で全く同じメカニズムを書いた:button_to<form method="post"> を生成、Turbo がそのフォームを傍受、レスポンスを TURBO_STREAM として処理、TURBO_STREAM はクロスオリジン 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 } が必要。1 個じゃなく、1 クラス。

落とし穴 #2:GitHub ボタンは credentials がない時も描画される

リポジトリを 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 が誤って消えた場合、壊れたボタンを押せない
  3. 本地と本番の credentials が同期していない時、機能が劣化するだけでエラーしない

agent に最初からこういう「credentials 保護」ガードを書かせるのは自動的じゃない——config が全部揃っている前提。feature 初版 + credentials guard の補完は 2 つのフェーズ、2 回に分けて agent に依頼するべき。

Amp + Claude Code の連携

今回の仕事は 2 フェーズに分かれた:

フェーズ 1(Amp、91e4f48:1 セッションで OAuth の全 boilerplate を敷いた——gem、モデル、migration、controller、view、ルート、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 に自分の session を記録する hook を書かせる 参照)で、デバッグの過程が記事素材として自動的に残る。

2 つのツールは衝突しない——Amp は 0 からの機能構築、長 thread での大きな議論に向くClaude Code は既存プロジェクトでの継続的インクリメンタル作業と工程自動化に向く。裏は同じ Claude Opus 4.7(1M context)、対話モデルとツールセットが違うだけ。

同じモデルを 2 つのツールで交替使用するのは、同じエンジニアが時に IDE、時にターミナルを使うのと同じ——それぞれ適材適所。


Claude(や Amp)に OAuth ログインを作らせる、完全チェックリスト:

  1. OAuth 自体は boilerplate と認める。焦点は callback の 3 ケース。
  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 と同じ家族のバグ。
  6. credentials 未設定の OAuth ボタンは描画しない。本地と本番の両方に保険。
  7. 初版 feature と credentials guard は 2 パス。一回のプロンプトで済ませようとしない。

OAuth 自体は難しくない。難しいのは、agent が自然には表面化しない境界ケース——並行性、孤児データ、本地環境の credentials 欠損——を認めること。人間の役割はその質問をすることだ:「並行性は?」「account 孤児は?」「credentials なしでどうなる?」。

問いを出す、agent が答えを書く。これがこのワークフローの真の分業。