Free

ترك Claude يبني تسجيل دخول SaaS: Google + GitHub OAuth وربط الحسابات

OAuth نفسه boilerplate؛ ربط الحسابات هو ما يعض. ثلاث حالات callback، مستخدمون بلا كلمة مرور، race conditions، Turbo يبتلع 302، حماية credentials — كل ذلك في مقال واحد.


ربط OAuth في how2claude، الصعب لم يكن OAuth نفسه.

تثبيت omniauth-google-oauth2 وomniauth-github، كتابة initializer، كتابة callback، إضافة زرين — هذا boilerplate لـ 10 دقائق أي AI agent يتقنه. الصعب هو ربط الحسابات: إذا كان بريد إلكتروني لديه حساب بكلمة مرور، هل يدمج تسجيل الدخول بـ Google؟ إذا كان مستخدم قد ربط Google مسبقاً ويريد إضافة GitHub، كيف يعمل الـ binding؟ إذا تسابق شخصان على أول تسجيل دخول بـ Google بنفس البريد، أينتهي الأمر بمستخدمين اثنين؟

هذه القصة الحقيقية. التنفيذ الأولي تم في 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]
  • إجراء callback لـ Auth::OmniauthController
  • المسارات: /auth/:provider/callback + /auth/failure
  • sessions/new.html.erb مع زري button_to (Google + GitHub)
  • 72 سطراً من اختبارات controller تغطي 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 مطابق) → سجّل دخول المستخدم المرتبط مسبقاً
  2. هل يوجد أحد مسجّل دخول الآن؟ (الجلسة فيها مستخدم) → اربط Google بالمستخدم الحالي
  3. لا هذا ولا ذاك؟ (حساب Google جديد، لا جلسة) → ابحث بالبريد أو أنشئ مستخدماً جديداً

الكود:

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 (سيكسر تسجيل دخول كلمة المرور).

افعل: أطفئ الـ validation الافتراضي، واكتب واحداً شرطياً بنفسك:

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 ميزة، ألقِ نظرة على سطح الاختبار — يوفر دورة إعادة تشغيل.

فخ #2: race condition لـ find-by-email

الفرع الثالث من الـ callback:

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

سليم في single-threaded. في الإنتاج، طلبان يصلان تقريباً في نفس الوقت (نادر لكنه يحدث) — كلاهما يرجع find_by بـ nil، كلاهما create!، واحد ينجح، والآخر يضرب unique index على email_address (بافتراض أن لديك واحداً).

أسوأ: إذا لم يكن لديك unique index على email_address، يُنشأ مستخدمان بنفس البريد. بعد ذلك: ربط Stripe customer غامض، بحث subscription خاطئ.

الإصلاح:

  • Unique index على مستوى DB: 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 عالي التزامن، عليك أن تسأل بنشاط "ما الذي يحدث تحت التزامن؟". الـ agents يفترضون افتراضياً وصولاً تسلسلياً لمستخدم واحد — اختباراتهم تغطي المسارات التسلسلية فقط.

فخ #3: مشكلة الملكية في ربط الحسابات

الفرع الثاني — مستخدم مسجّل دخول يربط OAuth جديداً — يبدو بسيطاً لكن فيه حالة حدية سيئة.

المستخدم A مسجّل بـ [email protected]. ينقر Continue with GitHub. callback GitHub يُرجع [email protected] (GitHub A يستخدم بريد العمل).

الآن قد تحتوي قاعدة البيانات:
- User A: email [email protected]
- User B: email [email protected] (A سجّل ببريد العمل قبل أشهر)

إذا شغلت Current.user.oauth_accounts.create!(...) كما يفعل الكود، يُربط GitHub بـ A. لكن User B لا يزال موجوداً، ربما بمشتريات B. B لا يزال يستطيع تسجيل الدخول بكلمة المرور، لكن GitHub الآن لـ A.

تدفق ربط كامل يجب أن يعالج "ماذا نفعل ببيانات الحساب اليتيم؟" — الـ 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 المحلي والإنتاج، الميزة تتدهور بدلاً من الخطأ

جعل الـ agent يكتب هذا النوع من "حارس credentials" من البداية ليس آلياً — يفترض أن config دائماً ممتلئ. scaffold الميزة + حارس credentials مرحلتان. افصلهما، لا تطلبهما في 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 تسجيل الجلسة الخاص بالمشروع (يلتقط كل شيء تلقائياً إلى docs/notes/pro/raw.md — انظر ترك Claude يكتب hook يسجل جلساته بنفسه). أثر التنقيح يصبح مادة مقال.

الأداتان لا تتصادمان. Amp لـ scaffolding من الصفر وthreads طويلة لمناقشات مفتوحة؛ Claude Code لعمل تراكمي داخل مشروع قائم ولأتمتة هندسية. نفس النموذج تحت (Claude Opus 4.7، 1M context) — أشكال تفاعل وtoolset مختلفة.

نفس المهندس، أحياناً في الـ IDE، أحياناً في الطرفية. كل منهما يناسب لحظات مختلفة.


ترك Claude (أو Amp) يبني تسجيل دخول OAuth — قائمة كاملة:

  1. اعترف أن OAuth نفسه boilerplate. ركّز على ثلاث حالات callback.
  2. مستخدمو OAuth-only: has_secure_password validations: false + طول شرطي. لا تزل auth كلمة المرور.
  3. أضف unique index DB على email + حارس تزامن على مستوى التطبيق لـ find-by-email. الـ agent لن يفعل.
  4. مشكلة الأيتام cross-user في ربط الحسابات: تحذير UI كحد أدنى. لا تتظاهر أنها غير موجودة.
  5. كل button_to لـ OAuth يحتاج data-turbo=false. نفس عائلة الـ bug مع Stripe.
  6. لا credentials → لا ترسم زر OAuth. يغطي انحراف dev وprod.
  7. scaffold الميزة وحارس credentials مرحلتان. لا تطلبهما في prompt واحد.

OAuth نفسه ليس صعباً. الصعب هو الاعتراف بحالات الحدود التي لا يثيرها الـ agents بشكل طبيعي — التزامن، البيانات اليتيمة، credentials مفقودة في البيئة المحلية. دورك كإنسان هو طرح تلك الأسئلة: "ماذا عن التزامن؟ ماذا عن أيتام الحسابات؟ ماذا لو لم تُضبط credentials؟"

أنت تطرح الأسئلة، الـ agent يكتب الإجابات. هذا هو التقسيم الحقيقي للعمل في هذا الـ workflow.