Free

לתת ל-Claude לבנות SaaS login: Google + GitHub OAuth ו-account linking

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. שני כלים שמעבירים את השרביט בצורה טבעית.


החלק של 10 דקות

Amp הוריד את ה-boilerplate במכה ב-91e4f48:

  • Gemfile: omniauth, omniauth-google-oauth2, omniauth-github
  • מודל OauthAccount: provider / uid / email / name / avatar_url, עם unique index על [provider, uid]
  • action callback של Auth::OmniauthController
  • מסלולים: /auth/:provider/callback + /auth/failure
  • sessions/new.html.erb עם שני button_to (Google + GitHub)
  • 72 שורות של controller tests המכסות 5 תרחישים
  • initializer של omniauth שמגדיר callback_path

מודל User קיבל שינוי של שורה אחת — והשורה הזו היא הטריק האמיתי (למטה).

14 קבצים, 257 שורות נוספו. החלק המכני של OAuth מסתיים כאן.

הבעיה האמיתית: ל-callback יש שלושה מקרים

כשהמשתמש לוחץ "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 חדש, אין session) → למצוא לפי אימייל או ליצור 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

ארבעים ושש שורות שנראות רגילות. כל ענף מסתיר מלכודת.

מלכודת #1: משתמשי OAuth-only ללא סיסמה

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_password
  • validates :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.

מלכודת #2: race condition של find-by-email

הענף השלישי של ה-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 שגויים.

תיקון:

  • Unique index ברמת DB: add_index :users, :email_address, unique: true
  • ברמת app: השתמש ב-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 עם concurrency גבוה, אתה חייב לשאול באופן אקטיבי "מה קורה פה ב-concurrency?". agents מניחים ברירת מחדל גישה סדרתית של משתמש יחיד — המבחנים שלהם מכסים רק נתיבים סדרתיים.

מלכודת #3: בעיית יתומים של account binding

הענף השני — משתמש מחובר מקשר 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 וממשיכה. זו פישוט מכוון: לתקן כשהבעיה באמת קופצת. אבל אתה כבן אדם צריך לדעת שהחור קיים.

בור #1: Turbo בולע את ה-302 של Google

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 }. לא כפתור אחד — מחלקה שלמה.

בור #2: כפתור GitHub מתרנדר בלי credentials

ריפו שזה עתה שובט, 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 → אין כפתור. שווה לעשות כי:

  1. מפתחים ששיבטו את הפרויקט לא רואים כפתור שבור
  2. אם credentials נעלמים בטעות בפרודקשן, אין כפתור שבור ללחוץ עליו
  3. כשה-credentials המקומיים וה-prod סוטים, הפיצ'ר מתדרדר במקום לתת שגיאה

לגרום ל-agent לכתוב שומר "credentials guard" כזה מההתחלה לא בא אוטומטית — הוא מניח שה-config תמיד מלא. scaffold feature + credentials guard זה שני מעברים. הפרד אותם, אל תבקש ב-prompt אחד.

Amp + Claude Code: שני כלים, מודל אחד

עבודת 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 — רשימת בדיקה מלאה:

  1. הכר שבר OAuth עצמו הוא boilerplate. התמקד בשלושת מקרי ה-callback.
  2. משתמשי OAuth-only: has_secure_password validations: false + אורך מותנה. אל תזרוק את auth הסיסמה.
  3. הוסף unique index DB על email + שומר concurrency ברמת app ל-find-by-email. ה-agent לא יעשה.
  4. בעיית יתומים cross-user של account binding: אזהרת UI מינימום. אל תעמיד פנים שהיא לא קיימת.
  5. כל button_to של OAuth צריך data-turbo=false. אותה משפחת באגים כמו Stripe.
  6. אין credentials → אל תרנדר את כפתור ה-OAuth. מכסה drift dev ו-prod.
  7. scaffold feature ו-credentials guard הם שני מעברים. אל תבקש ב-prompt אחד.

OAuth עצמו לא קשה. הקשה זה להכיר במקרי הגבול ש-agents לא מעלים באופן טבעי — concurrency, נתונים יתומים, credentials חסרים בסביבה מקומית. התפקיד שלך כבן אדם הוא לשאול את השאלות האלה: "מה עם concurrency? מה עם יתומי חשבון? מה אם credentials לא מוגדרים?"

אתה שואל את השאלות, ה-agent כותב את התשובות. זו חלוקת העבודה האמיתית ב-workflow הזה.