العودة إلى المدونة
7 أكتوبر 2026Sergei Solod12 دقائق قراءة

كيفية بناء SSO عبر نطاقات جذر مستقلة

معمارية عملية للمصادقة الموحّدة عبر نطاقات جذر مستقلة، باستخدام جلسات مقصورة على المضيف، وجهة إصدار مصادقة معروفة، وتذاكر تسليم قصيرة العمر وصالحة للاستخدام مرة واحدة، من دون إجبار كل زائر على المرور بإعادة توجيه للمصادقة.

تسجيل الدخول الموحّد عبر النطاقاتالمصادقةأمان الجلساتأمن الويبمعمارية الويب

كنت بحاجة إلى أن تتصرف عدة مواقع مستقلة كما لو كانت نظام حسابات واحدًا، من دون إضعاف عزل المتصفح المعتاد. كان بإمكان المستخدم تسجيل الدخول في نطاق واحد، ثم فتح نطاق آخر مباشرةً لاحقًا، والنقر على تسجيل الدخول، والمتابعة هناك من دون إدخال بيانات الاعتماد مرة أخرى.

كان القيد المهم أن هذه نطاقات جذر مستقلة. فلا يمكن ببساطة توسيع نطاق ملف تعريف ارتباط لجلسة أنشأه نطاق جذر ليشمل نطاق جذر آخر غير ذي صلة. وأصبح فهم التصميم أسهل بكثير عندما فصلت بين مفهومين كثيرًا ما يختلطان: الهوية العامة وجلسة المتصفح المحلية.

النموذج الناتج مباشر: تتعرّف الواجهة الخلفية على هوية حساب مشتركة واحدة، ويملك كل نطاق جلسته الخاصة المرتبطة بالمضيف، ويتيح تسليم قصير العمر وصالح للاستخدام مرة واحدة لمصدر موثوق ومصادَق عليه أن يصرّح بإنشاء جلسة جديدة على مصدر موثوق آخر.

الهوية العامة لا تتطلب ملف تعريف ارتباط مشتركًا

يمكن أن يكون الحساب عامًا حتى عندما تكون جلسات المتصفح محلية.

يحتفظ كل موقع بملف تعريف ارتباط للجلسة مقصور على المضيف. ولا يمكن توسيع ملف تعريف ارتباط صادر عن نطاق جذر ليشمل نطاق جذر آخر غير ذي صلة عبر السمة Domain. فهذه السمة لا تستطيع توسيع نطاق ملف تعريف الارتباط إلا ضمن التسلسل الهرمي للنطاقات الذي يُسمح للمضيف المُصدر باستخدامه.

بالنسبة إلى جلسة ينبغي أن تظل مرتبطة بمضيف واحد، يلائمها جيدًا ملف تعريف ارتباط يبدأ بالبادئة __Host-. من حيث المبدأ:

Set-Cookie: __Host-<session-cookie>=<opaque-token>; Path=/; Secure; HttpOnly; SameSite=Lax

الخاصية المهمة هنا هي الحدود:

  • لا يستطيع الموقع A قراءة ملف تعريف ارتباط الجلسة المقصور على المضيف الخاص بالموقع B.
  • لا يستطيع الموقع B قراءة ملف تعريف ارتباط الجلسة المقصور على المضيف الخاص بالموقع A.
  • ومع ذلك، يمكن لكلتا الجلستين أن تُحالا إلى الهوية نفسها في الواجهة الخلفية.

لذلك، لا ينبغي لـ SSO عبر النطاقات أن يحاول نقل ملف تعريف ارتباط من موقع إلى آخر. بل ينبغي أن ينقل إثباتًا مؤقتًا على أن مصدرًا موثوقًا سبق أن صادق على المستخدم.

نمط التسليم لمرة واحدة

يوفّر المصدر المصادَق عليه عملية تسليم صغيرة. ولا يستدعيها المتصفح إلا عندما تكون هناك حاجة إلى انتقال مصادقة بين المواقع.

تنشئ الواجهة الخلفية تذكرة عشوائية مشفّرة بقوة، وتعيد القيمة الخام إلى المتصفح مرة واحدة، ولا تخزّن سوى تجزئة لها مع الحد الأدنى من المعلومات اللازمة للتحقق من صحة النقل:

{
  tokenHash,
  userId,
  sourceOrigin,
  targetSite,
  returnPath,
  expiresAt
}

ينبغي أن تكون التذكرة قصيرة العمر وصالحة لهدف واحد فقط. كما ينبغي أن يكون طلب الإصدار من جهة المصدر مصادَقًا عليه أصلًا ومحميًا من CSRF. ويجب التحقق من الهدف المطلوب ووجهة العودة قبل إصدار أي شيء.

بعد ذلك ينفّذ المتصفح طلب POST على المستوى الأعلى إلى الهدف. يستهلك الهدف التذكرة، ويحدّد هوية المستخدم المشتركة، وينشئ جلسته المحلية الخاصة، ثم يعيد توجيه المتصفح إلى المسار المقصود.

من حيث المبدأ:

record = consumeOnce({
  tokenHash: hash(ticket),
  targetSite: currentSite,
  sourceOrigin: requestOrigin,
  notExpired: true
})

if (!record) deny()

user = loadSharedUser(record.userId)
createLocalSession(user)
redirect(record.returnPath)

التذكرة ليست جلسة الهدف. إنها تصريح لمرة واحدة بإنشاء جلسة الهدف تلك.

الاستهلاك الذري يغلق نافذة إعادة الاستخدام

قد يحمّل تنفيذ أضعف التذكرة، ويتحقق منها، وينشئ جلسة، ثم يحذف التذكرة بعد ذلك. وهذا يخلق نافذة يمكن فيها لطلبين أن يريا السجل نفسه بينما لا يزال صالحًا.

التصميم الأكثر أمانًا هو جمع التحقق والإبطال في عملية استهلاك ذرية واحدة. إذا لم يطابق أي سجل الشروط، تفشل المصادقة. وإذا أُعيد سجل، تكون تلك التذكرة قد أزيلت بالفعل من مجموعة التذاكر القابلة للاستخدام قبل إنشاء الجلسة.

ويتيح ذلك أيضًا للهدف ربط عدة شروط بالعملية نفسها: الهدف المتوقع، ومصدر الطلب المتوقع، وتجزئة الرمز، ووقت الانتهاء.

يجب أن يكون التحقق من المبدأ قائمًا على الرفض عند الفشل

يضيف ربط التذكرة بالمبدأ خاصية مفيدة أخرى: لا ينبغي أن تكون التذكرة الصادرة عبر مصدر موثوق قابلة للاستهلاك من مبدأ غير ذي صلة.

ينبغي أن يكون فحص المبدأ جزءًا من شرط الاستهلاك نفسه. وهذا يعني أن طلبًا من مبدأ خاطئ لا يستهلك التذكرة الصالحة، بينما يظل بإمكان المصدر الشرعي إكمال التسليم بعد ذلك.

هناك قيد مهم: ليس مضمونًا أن يحتوي الرأس Origin على سلسلة مبدأ عادية في كل سياق داخل المتصفح. قد تصل بعض الطلبات من دون قيمة قابلة للاستخدام، ويمكن للسياقات المبهمة أن تنتج Origin: null.

قاعدتي هي اعتبار Origin المفقود أو المبهم فشلًا في فحص المبدأ، ما لم يكن لدى التطبيق آلية بديلة وصريحة للتحقق. ولا ينبغي أن تصبح القيمة الحرفية null مبدأً موثوقًا به لمجرد التوافق.

هذا اختيار يقوم على الرفض عند الفشل. فإذا كان تدفق شرعي يحتاج إلى دعم سياق لا يوفّر مبدأً قابلًا للاستخدام، فينبغي تصميم هذا الاستثناء صراحةً بدل إضعاف الفحص العام بصمت.

أبقِ بيانات اعتماد التسليم خارج عنوان URL

أفضل إرسال التذكرة المؤقتة في POST لنموذج على المستوى الأعلى بدل وضعها في سلسلة استعلام.

نسخة عامة من جهة العميل تبدو هكذا:

const form = document.createElement('form');
form.method = 'POST';
form.action = targetOrigin + '/auth/consume';

const input = document.createElement('input');
input.type = 'hidden';
input.name = 'ticket';
input.value = ticket;

form.append(input);
document.body.append(form);
form.submit();

يقبل الهدف التذكرة من جسم POST المتوقع ويرفض المحاولات المكافئة لتقديمها عبر عنوان URL.

هذا لا يجعل بيانات الاعتماد غير مؤذية، لكنه يُبقيها خارج عناوين URL العادية للتنقل ويقلل التعرض العرضي عبر سجل عناوين URL والروابط المنسوخة ومعلومات الإحالة والسجلات التي تركز على عناوين URL.

يمكن للروابط بين المواقع تفعيل التسليم تلقائيًا عند وجود مصادقة

إذا كان الموقع الحالي يعرف بالفعل أن المستخدم مصادَق عليه، فيمكن لرابط عادي إلى موقع موثوق آخر أن ينفّذ التسليم عند الحاجة.

ينبغي أن يظل الرابط يحتوي على href حقيقي. إن SSO تحسين للتنقل العادي، وليس بديلًا عنه.

يبدو تدفق عميل مبسّط هكذا:

if (session.status !== 'authenticated') {
  return; // ordinary browser navigation
}

if (!isTrustedTarget(destination)) {
  return;
}

event.preventDefault();

const ticket = await requestHandoff({
  targetSite,
  returnPath: destinationPath
});

postTicketToTarget(ticket);

ينبغي لنسخة الإنتاج أيضًا أن تحمي من عمليات الإرسال المكررة، وأن تتحقق من الهدف مقابل سجل للمواقع الموثوقة، وأن تعود إلى التنقل العادي إذا تعذر إنشاء التسليم.

ينبغي أن يبقى سلوك المتصفح الأصلي كما هو. فلا يجب تحويل النقر بالزر الأوسط، أو النقر مع مفاتيح التعديل، أو التنزيلات، أو الروابط المقصودة لسياق تصفح آخر بصمت إلى طلبات مصادقة.

زر تسجيل الدخول في الموقع الثاني يحتاج إلى جهة إصدار معروفة

تبدأ الحالة الأصعب عندما يفتح المستخدم الموقع B مباشرةً. لا يملك الموقع B جلسة محلية، ولذلك يتعامل مع الزائر بصورة صحيحة على أنه مجهول حتى لو كان المتصفح لا يزال يملك جلسة صالحة على الموقع A.

لا يستطيع الموقع B فحص ملف تعريف الارتباط المقصور على المضيف الخاص بالموقع A. كما لا يمكنه إعادة التوجيه إلى موقع C عشوائي وتوقّع أن يكتشف ذلك الموقع الجلسات التي تخص بقية النظام. فكل نطاق جذر غير ذي صلة لا يستطيع رؤية سوى ملفات تعريف الارتباط التي تخصه.

وهذا يعني أن تدفق تسجيل الدخول للمستخدم المجهول يحتاج إلى جهة إصدار معروفة: إما مبدأ مصادقة مركزيًا، أو موقعًا موثوقًا محددًا يُتوقع أن يحتفظ بجلسة تسجيل الدخول القابلة لإعادة الاستخدام.

في الحالة البسيطة للانتقال من الموقع A إلى الموقع B، يكون التسلسل كالتالي:

  1. يسجل المستخدم الدخول في الموقع A.
  2. لاحقًا، يفتح المستخدم الموقع B مباشرةً.
  3. لا يملك الموقع B جلسة، لذلك يعرض تسجيل الدخول.
  4. ينقر المستخدم على تسجيل الدخول.
  5. ينفّذ المتصفح انتقالًا على المستوى الأعلى إلى الموقع A، وهو جهة الإصدار المحددة.
  6. يستطيع الموقع A قراءة ملف تعريف ارتباط جلسته الحالية المقصور على المضيف.
  7. لأن تلك الجلسة لا تزال صالحة، يصدر الموقع A فورًا تسليمًا صالحًا لمرة واحدة إلى الموقع B.
  8. يرسل المتصفح التذكرة إلى الموقع B عبر POST.
  9. ينشئ الموقع B جلسته المحلية الخاصة ويعيد المستخدم إلى الصفحة المطلوبة.

من منظور المستخدم، هذه إعادة توجيه سريعة إلى الموقع A ثم العودة. لا حاجة إلى كلمة مرور لأن الموقع A لديه بالفعل الجلسة التي يُسمح له برؤيتها.

والقيد مهم بالقدر نفسه: إذا كان المستخدم مسجل الدخول فقط في الموقع C، فلن تفيد إعادة توجيه الموقع B إلى الموقع A إلا إذا كان الموقع A وسيط مصادقة مركزيًا حقيقيًا لديه معرفة مستقلة خاصة به بحالة تسجيل الدخول. فقاعدة بيانات مستخدمين مشتركة لا تجعل جلسات المتصفح مشتركة.

لماذا تُبسّط جهة إصدار مركزية النظام

كلما زاد عدد المواقع المستقلة، ألغت جهة الإصدار المركزية الحاجة إلى اكتشاف الجلسات عبر نطاقات عشوائية.

من دونها، سيحتاج الموقع B المجهول بطريقة ما إلى تحديد أي موقع آخر قد يملك حاليًا ملف تعريف ارتباط مفيدًا. لا يستطيع الموقع A فحص ملف تعريف ارتباط الموقع C، ولا يستطيع الموقع C فحص ملف تعريف ارتباط الموقع A، ولا يستطيع الموقع B فحص أي منهما.

تمنح جهة الإصدار المركزية كل محاولة تسجيل دخول لمستخدم مجهول مسارًا واحدًا يمكن التنبؤ به:

Site B
  → authentication issuer
  → issuer session exists?
      yes → create one-time handoff
      no  → show normal login
  → Site B consumes handoff
  → Site B creates local session

لا تحتاج جهة الإصدار إلى خدمة بقية التطبيق. ومسؤوليتها ذات الصلة هي امتلاك جلسة المصادقة القابلة لإعادة الاستخدام وإصدار عمليات تسليم محددة النطاق بدقة إلى المواقع الموثوقة.

في نظام أصغر، يمكن لموقع موجود أن يؤدي هذا الدور. وفي معمارية أوسع، يمكن لمبدأ مصادقة مخصص أن يجعل نموذج الثقة أسهل فهمًا.

لا تمرّر كل زائر عبر جهة الإصدار

ينبغي ألا يعمل تدفق جهة الإصدار تلقائيًا مع كل مشاهدة مجهولة للصفحة.

إن إعادة توجيه كل زائر إلى نطاق آخر لمجرد التحقق من وجود جلسة حالية تخلق تنقلًا غير ضروري، وزمن استجابة إضافيًا، ومزيدًا من أنماط الفشل، وتحليلات أكثر ضوضاءً، وسلوك تخزين مؤقت أكثر تعقيدًا.

كما أنها غير مناسبة للصفحات العامة الحساسة لـ SEO. فالزاحف أو الزائر العادي الذي يطلب عنوان URL عامًا ينبغي أن يتلقى الصفحة العامة مباشرةً بدل إجباره أولًا على المرور بمسار التفافي للمصادقة.

القاعدة التي أستخدمها هي:

لا تستعلم عن جهة الإصدار عند تحميل الصفحة. ابدأ المصادقة عبر النطاقات فقط بعد إجراء صريح لتسجيل الدخول أو بعد تنقل مقصود بين المواقع من جلسة مصادَق عليها.

يبقي هذا حركة الزيارات العامة بسيطة، مع الحفاظ على سرعة المصادقة للمستخدم الذي لديه بالفعل جلسة صالحة لدى جهة الإصدار المحددة.

التحقق من مسار العودة يستحق عناية أكبر من فحص بادئة

يحتاج التسليم عادةً إلى تذكّر المكان الذي ينبغي أن يصل إليه المتصفح بعد ذلك. ويمكن لهذه القيمة أن تصبح إعادة توجيه مفتوحة إذا عوملت كعنوان URL عشوائي يتحكم فيه العميل.

حيثما أمكن، يكون التصميم الأكثر أمانًا هو عدم قبول عنوان URL كامل للوجهة أصلًا. فمعرّف قصير أو ربط على جهة الخادم لوجهات معروفة يقلل مقدار تحليل عناوين URL والتحقق المطلوب.

إذا كان لا بد من قبول مسار نسبي، فتحقق منه بعناية وأبقِه محصورًا في الموقع الهدف.

مثال مبسّط عمدًا — وليس روتينًا كاملًا للتحقق من إعادة التوجيه — هو:

function isSafeReturnPath(value) {
  if (!value.startsWith('/')) return false;
  if (value.startsWith('//')) return false;
  if (value.includes('\\\\')) return false;

  const base = 'https://example.invalid';
  const parsed = new URL(value, base);

  return parsed.origin === base;
}

يحتاج التنفيذ الحقيقي أيضًا إلى مراعاة سلوك فك الترميز، والمدخلات المشوهة، ومحارف التحكم، والتطبيع، وقواعد التوجيه في التطبيق، وأي تحويلات يجريها الوكيل أو إطار العمل قبل إنتاج إعادة التوجيه.

ثابت الأمان أبسط من تفاصيل المحلل: يجوز للتسليم اختيار موقع مسموح به داخل التطبيق الهدف، ولا يجوز له أبدًا اختيار وجهة خارجية عشوائية.

حالات الفشل أهم من المسار الناجح

يثبت تسجيل الدخول الناجح فقط أن المسار الأساسي يعمل. أما الاختبارات الأكثر فائدة فهي التي تختبر حدود الثقة.

تحققت من السلوك في حالات مثل:

  • محاولة مصدر غير مصادَق عليه إصدار تسليم؛
  • غياب حماية CSRF؛
  • مبدأ مفقود أو مبهم أو غير متوقع؛
  • هدف غير موثوق؛
  • وجهة عودة غير آمنة؛
  • تقديم التذكرة إلى الهدف الخطأ؛
  • محاولة استهلاك من مبدأ خاطئ؛
  • إعادة استخدام التذكرة؛
  • انتهاء صلاحية التذكرة؛
  • مستخدم لم يعد موجودًا؛
  • تقديم التذكرة عبر عنوان URL بدل جسم POST المتوقع.

هناك ثابتان مفيدان بصورة خاصة للاختبار المباشر: يجب ألا يؤدي الطلب المرفوض عن طريق الخطأ إلى إتلاف تذكرة لا تزال صالحة للتدفق الشرعي، ويجب أن يجعل الاستهلاك الناجح تلك التذكرة غير قابلة للاستخدام فورًا.

يتوسع البروتوكول من دون تكاملات ثنائية بين المواقع

لا يتغير تصميم التسليم جوهريًا عند إضافة مزيد من المواقع.

يحتاج كل موقع مشارك إلى العقد الصغير نفسه:

  • معرّف منطقي ثابت؛
  • مبدأ عام موثوق؛
  • الوصول إلى هوية الحساب المشتركة؛
  • عملية لإصدار التسليم للمصادر المصادَق عليها؛
  • عملية لاستهلاك التسليم؛
  • سجل للمواقع الموثوقة؛
  • جلسة محلية مرتبطة بالمضيف؛
  • سياسة آمنة لوجهة العودة؛
  • جهة إصدار معروفة لتدفقات تسجيل الدخول المجهولة.

خيار التوسع المهم هو تجنب منطق مصادقة مخصص لكل زوج من المواقع. يمكن لموقع مصادَق عليه بالفعل أن يصدر النوع نفسه من التسليم إلى موقع موثوق آخر. ويمكن لموقع مجهول إرسال المتصفح إلى جهة الإصدار المعروفة.

وهكذا يبقى النظام قائمًا على بروتوكول واحد بدل مصفوفة متزايدة من الحالات الخاصة.

قابلية نقل تسجيل الدخول منفصلة عن نشر تسجيل الخروج

تخلق الجلسات المستقلة المرتبطة بالمضيف تمييزًا مفيدًا آخر: جعل تسجيل الدخول قابلًا للنقل لا يحدد تلقائيًا تسجيل خروج عالميًا.

إذا سجّل المستخدم الخروج من الموقع A، فقد تظل الجلسة المحلية لموقع آخر صالحة ما لم تُبطلها الواجهة الخلفية عمدًا. هذه سياسة لإدارة الجلسات، وليست فشلًا في تسليم SSO.

يمكن للنظام الاختيار بين تسجيل خروج محلي، أو تسجيل خروج عالمي، أو إدارة صريحة للجلسات. وإبقاء هذا القرار منفصلًا عن التسليم يجعل فهم الآليتين أسهل.

النموذج الذي أستخدمه

أصبحت المعمارية أبسط عندما توقفت عن وصف المشكلة بأنها «مشاركة ملفات تعريف ارتباط تسجيل الدخول بين النطاقات».

هناك هوية عامة واحدة، ويملك كل نطاق جلسة المتصفح الخاصة به، ويتيح تسليم قصير العمر وصالح للاستخدام مرة واحدة لمصدر موثوق ومصادَق عليه أن يصرّح بإنشاء جلسة محلية أخرى.

عند الدخول مباشرةً إلى موقع مجهول، توجد قاعدة إضافية مهمة: يجب إرسال المتصفح إلى جهة إصدار تستطيع فعليًا رؤية جلسة المستخدم الحالية. إذا كان المستخدم قد سجّل الدخول سابقًا لدى جهة الإصدار هذه، فقد تكون الرحلة ذهابًا وإيابًا شبه غير ملحوظة. وإذا لم تكن لدى جهة الإصدار جلسة صالحة، فيجب أن تعود إلى المصادقة العادية بدل التظاهر بأن جلسة نطاق آخر غير ذي صلة مرئية.

يحافظ هذا النموذج على حدود النطاقات في المتصفح، ويتجنب إعادة توجيه المصادقة غير الضرورية للزوار العموميين، ويظل يوفّر التجربة المقصودة: سجّل الدخول لدى جهة الإصدار، وافتح موقعًا موثوقًا آخر لاحقًا، وانقر على تسجيل الدخول، ثم عد بجلسة محلية جديدة من دون إدخال كلمة المرور مرة أخرى.