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. أداتان تتسلمان العصا بشكل طبيعي.
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 مطابق) → سجّل دخول المستخدم المرتبط مسبقاًالكود:
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 (سيكسر تسجيل دخول كلمة المرور).
افعل: أطفئ الـ validation الافتراضي، واكتب واحداً شرطياً بنفسك:
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 ميزة، ألقِ نظرة على سطح الاختبار — يوفر دورة إعادة تشغيل.
الفرع الثالث من الـ 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 خاطئ.
الإصلاح:
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 عالي التزامن، عليك أن تسأل بنشاط "ما الذي يحدث تحت التزامن؟". الـ agents يفترضون افتراضياً وصولاً تسلسلياً لمستخدم واحد — اختباراتهم تغطي المسارات التسلسلية فقط.
الفرع الثاني — مستخدم مسجّل دخول يربط 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 وتمضي. هذه تبسيط متعمد: رقّعها حين تظهر المشكلة فعلاً. لكنك كإنسان تحتاج أن تعرف أن الثقب موجود.
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" من البداية ليس آلياً — يفترض أن config دائماً ممتلئ. scaffold الميزة + حارس credentials مرحلتان. افصلهما، لا تطلبهما في 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 تسجيل الجلسة الخاص بالمشروع (يلتقط كل شيء تلقائياً إلى docs/notes/pro/raw.md — انظر ترك Claude يكتب hook يسجل جلساته بنفسه). أثر التنقيح يصبح مادة مقال.
الأداتان لا تتصادمان. Amp لـ scaffolding من الصفر وthreads طويلة لمناقشات مفتوحة؛ Claude Code لعمل تراكمي داخل مشروع قائم ولأتمتة هندسية. نفس النموذج تحت (Claude Opus 4.7، 1M context) — أشكال تفاعل وtoolset مختلفة.
نفس المهندس، أحياناً في الـ IDE، أحياناً في الطرفية. كل منهما يناسب لحظات مختلفة.
ترك Claude (أو Amp) يبني تسجيل دخول OAuth — قائمة كاملة:
has_secure_password validations: false + طول شرطي. لا تزل auth كلمة المرور.button_to لـ OAuth يحتاج data-turbo=false. نفس عائلة الـ bug مع Stripe.OAuth نفسه ليس صعباً. الصعب هو الاعتراف بحالات الحدود التي لا يثيرها الـ agents بشكل طبيعي — التزامن، البيانات اليتيمة، credentials مفقودة في البيئة المحلية. دورك كإنسان هو طرح تلك الأسئلة: "ماذا عن التزامن؟ ماذا عن أيتام الحسابات؟ ماذا لو لم تُضبط credentials؟"
أنت تطرح الأسئلة، الـ agent يكتب الإجابات. هذا هو التقسيم الحقيقي للعمل في هذا الـ workflow.