OAuth זה boilerplate; account linking זה איפה שזה נושך. שלושה מקרים של callback, משתמשים בלי סיסמה, race conditions, Turbo בולע 302, שמירה על credentials — הכל במאמר אחד.
לחבר OAuth ל-how2claude לא היה קשה בגלל OAuth עצמו.
להתקין את omniauth-google-oauth2 ו-omniauth-github, לכתוב initializer, לכתוב callback, להוסיף שני כפתורים — זה 10 דקות של boilerplate שכל AI agent עושה נכון. הקשה היא account linking: אם אימייל כבר קיים עם חשבון סיסמה, האם login עם Google ממזג? אם משתמש כבר קישר את Google ורוצה להוסיף GitHub, איך עובד ה-binding? אם שני אנשים מתחרים ב-login ראשון של Google עם אותו אימייל, האם מקבלים שני User?
זה הסיפור האמיתי. המימוש ההתחלתי נעשה ב-Amp (91e4f48) — Amp בצד השני רץ על Claude, עם מודל אינטראקציה מתאים מאוד להנחת תשתית גדולה בבת אחת. ימים אחר כך, 0112888 ב-Claude Code טיפל בקונפליקט Turbo-נגד-OAuth. שני כלים שמעבירים את השרביט בצורה טבעית.
Amp הוריד את ה-boilerplate במכה ב-91e4f48:
Gemfile: omniauth, omniauth-google-oauth2, omniauth-githubOauthAccount: provider / uid / email / name / avatar_url, עם unique index על [provider, uid]Auth::OmniauthController/auth/:provider/callback + /auth/failuresessions/new.html.erb עם שני button_to (Google + GitHub)omniauth שמגדיר callback_pathמודל User קיבל שינוי של שורה אחת — והשורה הזו היא הטריק האמיתי (למטה).
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
ארבעים ושש שורות שנראות רגילות. כל ענף מסתיר מלכודת.
has_secure_password ברירת המחדל של Rails 8 דורש password_digest. משתמש שנכנס עם Google אין לו סיסמה — אז מה?
אל תסיר את has_secure_password (יישבור את ה-login עם סיסמה).
כן: כבה את הוולידציה ברירת המחדל, וכתוב שלך מותנית:
class User < ApplicationRecord
has_secure_password validations: false
validates :password, length: { minimum: 8 }, if: -> { password.present? }
# ...
end
validations: false: מוריד את הכלל "סיסמה חובה" שמגיע עם has_secure_passwordvalidates :password, length:, if: password.present?: אם המשתמש מגדיר סיסמה, דרוש מינימום 8; אחרת לא אכפתמשתמשי OAuth-only נוצרים עם password_digest: "". יכולים להוסיף סיסמה אחר כך (אם זה 8+ תווים). משתמשי auth עם סיסמה לא מושפעים בכלל.
תופעת לוואי: המבחן הקיים של "password reset" נשבר — השתמש בסיסמה קצרה מדי. Amp תיקן באותו commit (test/controllers/passwords_controller_test.rb). כש-agent מוסיף feature, זרוק מבט על שטח הבדיקות — זה חוסך מחזור re-run.
הענף השלישי של ה-callback:
user = User.find_by(email_address: auth.info.email) || User.create!(...)
בסדר ב-single-thread. בפרודקשן שתי בקשות מגיעות כמעט בו-זמנית (נדיר אבל קורה) — שניהם find_by מחזירים nil, שניהם create!, אחד מצליח, השני מתנגש ב-unique index על email_address (בהנחה שיש לך).
יותר גרוע: אם אין לך unique index על email_address, נוצרים שני 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 כותב קוד DB עם concurrency גבוה, אתה חייב לשאול באופן אקטיבי "מה קורה פה ב-concurrency?". agents מניחים ברירת מחדל גישה סדרתית של משתמש יחיד — המבחנים שלהם מכסים רק נתיבים סדרתיים.
הענף השני — משתמש מחובר מקשר OAuth provider חדש — נראה פשוט, אבל יש בו מקרה גבול רע.
משתמש A מחובר בתור [email protected]. לוחץ Continue with GitHub. callback של GitHub מחזיר [email protected] (ה-GitHub של A משתמש באימייל עבודה).
עכשיו ה-DB יכול להכיל:
- User A: email [email protected]
- User B: email [email protected] (A נרשם עם אימייל עבודה לפני חודשים)
אם תריץ Current.user.oauth_accounts.create!(...) כמו שהקוד עושה, GitHub מתקשר ל-A. אבל User B עדיין קיים, אולי עם Purchases של B. B עדיין יכול להיכנס עם סיסמה, אבל ה-GitHub שלו עכשיו של A.
flow binding מלא חייב לטפל ב"מה אנחנו עושים עם הנתונים היתומים של החשבון?" — agents לא חושבים על זה ברירת מחדל. לפחות אזהרת 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 לשלב שני מסלולי תשלום: Stripe + x402 כיסה בדיוק את אותו מנגנון: button_to מייצר <form method="post">, Turbo מיירט את הטופס, מטפל בתגובה כ-TURBO_STREAM, ו-TURBO_STREAM לא עוקב אחר 302 cross-origin.
התיקון נחת ב-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 }. לא כפתור אחד — מחלקה שלמה.
ריפו שזה עתה שובט, credentials של GitHub לא מוגדרים — דף הכניסה עדיין מרנדר "Continue with GitHub". לחיצה נותנת Invalid client_id.
אותו commit (0112888) הוסיף שומר:
<% if Rails.application.credentials.dig(:github, :client_id).present? %>
<%= button_to "/auth/github", ... %>
<% end %>
אין credentials GitHub → אין כפתור. שווה לעשות כי:
לגרום ל-agent לכתוב שומר "credentials guard" כזה מההתחלה לא בא אוטומטית — הוא מניח שה-config תמיד מלא. scaffold feature + credentials guard זה שני מעברים. הפרד אותם, אל תבקש ב-prompt אחד.
עבודת OAuth התחלקה לשני שלבים:
שלב 1 (Amp, 91e4f48): סשן אחד שמניח את כל לוח OAuth — gems, model, migration, controller, view, routes, tests, i18n. 14 קבצים / 257 שורות / 20 דקות. מודל האינטראקציה של Amp מתאים ל"דחיפת חתיכה גדולה בכל פעם", עם היסטוריית thread לינארית ונקייה.
שלב 2 (Claude Code, 0112888): ימים אחר כך, לפני העלייה לאוויר, Turbo-נגד-OAuth תפס אותנו. טריטוריה של אבחון מדויק + תיקון שורה אחת + מבחן רגרסיה. ל-Claude Code יש הקשר git/session מלא בתוך ספריית הפרויקט, ועוד hook הקלטת session של הפרויקט עצמו (לוכד הכל אוטומטית ל-docs/notes/pro/raw.md — ראה לתת ל-Claude לכתוב hooks שמקליטים אותו עצמו). עקבות ה-debug הופכים לחומר מאמר.
שני הכלים לא מתנגשים. Amp ל-scaffolding מאפס ול-threads ארוכים של דיון פתוח; Claude Code לעבודה אינקרמנטלית בתוך פרויקט קיים ואוטומציה הנדסית. אותו מודל מתחת (Claude Opus 4.7, 1M הקשר) — צורות אינטראקציה וערכות כלים שונות.
אותו מהנדס, לפעמים ב-IDE, לפעמים בטרמינל. כל אחד מתאים לרגעים שונים.
לתת ל-Claude (או Amp) לבנות login OAuth — רשימת בדיקה מלאה:
has_secure_password validations: false + אורך מותנה. אל תזרוק את auth הסיסמה.button_to של OAuth צריך data-turbo=false. אותה משפחת באגים כמו Stripe.OAuth עצמו לא קשה. הקשה זה להכיר במקרי הגבול ש-agents לא מעלים באופן טבעי — concurrency, נתונים יתומים, credentials חסרים בסביבה מקומית. התפקיד שלך כבן אדם הוא לשאול את השאלות האלה: "מה עם concurrency? מה עם יתומי חשבון? מה אם credentials לא מוגדרים?"
אתה שואל את השאלות, ה-agent כותב את התשובות. זו חלוקת העבודה האמיתית ב-workflow הזה.