OAuth 自体は boilerplate、刺さるのは account linking。callback の 3 ケース、パスワードなしユーザー、race condition、Turbo が 302 を飲む、credentials ガード——全部ここに。
how2claude に OAuth を組み込む、本当に難しかったのは OAuth そのものじゃない。
omniauth-google-oauth2 と omniauth-github を入れて、initializer を書いて、callback を書いて、ボタンを 2 つ追加する——これは 10 分の boilerplate、どの AI agent でも正しくこなす。難しいのは account linking:既存のメールでパスワードアカウントがある場合、Google ログインで統合するか?Google を先に繋げたユーザーが後から GitHub を追加したい場合、binding ロジックはどう書く?2 人が同じメールで同時に初 Google ログインしたら User が 2 個できないか?
実践の痛みを書く。初期実装は Amp(91e4f48)で行った——Amp の裏は Claude、大きな土台を一気に敷き詰めるのに適した対話モデル。数日後、0112888 で Claude Code 側から Turbo と OAuth の衝突を修正した。2 つのツールが自然に連携する。
Amp が 91e4f48 で boilerplate 部分を一発で仕上げた:
Gemfile:omniauth、omniauth-google-oauth2、omniauth-githubOauthAccount モデル:provider / uid / email / name / avatar_url、[provider, uid] に unique indexAuth::OmniauthController の callback アクション/auth/:provider/callback + /auth/failuresessions/new.html.erb に button_to 2 つ(Google + GitHub)omniauth initializer で callback_path を設定User モデルは 1 行だけ変更、その 1 行がコア(後述)。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: "" で作成される。後からパスワードを設定することも可能(8 文字以上であれば)。パスワード認証ユーザーには影響なし。
副作用:既存の "password reset" テストが壊れる——短すぎるパスワードを使っていた。Amp は同じコミットで修正した(test/controllers/passwords_controller_test.rb)。agent に機能を書かせるとき、テストへの影響範囲をさっと確認しておくと、re-run サイクルが 1 回減る。
第 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 が正しくない。
対策:
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 に高並行安全の DB 操作を書かせる場合、「ここの並行性どうなる?」と能動的に問う必要がある。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 の下に紐付く。でも User B はまだ生きていて、B の Purchase があるかもしれない。B はパスワードログインできるが、GitHub は A に奪われた。
完全な binding ロジックは「相手側アカウントの孤児データをどうするか」まで扱う必要がある——これは agent がデフォルトで考えてくれることじゃない。最低限 UI で警告:「この GitHub アカウントはメール [email protected] に紐付いています。続けるとそのアカウントは GitHub でログインできなくなります」。
現バージョンはこれを扱わない、Current.user と OAuth をそのまま粗く紐付けるだけ。これは意図的な簡略化——実際に問題が起きたら補う。でも人間として、穴があることは把握しておくべき。
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 クラス。
リポジトリを 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 保護」ガードを書かせるのは自動的じゃない——config が全部揃っている前提。feature 初版 + credentials guard の補完は 2 つのフェーズ、2 回に分けて agent に依頼するべき。
今回の仕事は 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 ログインを作らせる、完全チェックリスト:
has_secure_password validations: false + 条件付き長さバリデーション。password 認証を削除しない。button_to に data-turbo=false。Stripe と同じ家族のバグ。OAuth 自体は難しくない。難しいのは、agent が自然には表面化しない境界ケース——並行性、孤児データ、本地環境の credentials 欠損——を認めること。人間の役割はその質問をすることだ:「並行性は?」「account 孤児は?」「credentials なしでどうなる?」。
問いを出す、agent が答えを書く。これがこのワークフローの真の分業。