← All posts

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

Authagonal·July 23, 2026
oidcssocspfrontenddebuggingwar-story

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

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

هذه حكاية ما كان ينتظره، ولماذا كان الشيء الذي انتظره محجوبًا برأس أمان كنّا فخورين به.

ما تفعله البوابة عند التحميل

بوابتنا هي تطبيق أحادي الصفحة. عندما تُحمَّل، وقبل أن تُظهر لك أي شيء، تحاول الإجابة عن سؤال واحد: هل سبق أن سجّلت دخولك؟ الطريقة اللبقة للقيام بذلك مع OIDC هي فحص صامت. يسأل التطبيق مزوّد الهوية: «إن كان لهذا المتصفح جلسة قائمة بالفعل، فسلّمني رمزًا جديدًا دون إزعاج المستخدم.» تُتيح مكتبة العميل لدينا، oidc-client-ts، هذا عبر signinSilent()، وقد استدعيناها عند التحميل من مساعِد renewSession().

ثمة طريقتان يمكن أن يجري بهما هذا الفحص الصامت. إن كان التطبيق يحمل رمز تحديث (refresh token)، تُجري المكتبة تبادلًا هادئًا عبر قناة خلفية، دون أي واجهة مستخدم، وتكون قد دخلت. أمّا إن كان لا يحمل رمز تحديث، فتلجأ المكتبة إلى الآلية الأقدم: تفتح إطار iframe مخفيًّا موجّهًا نحو نقطة نهاية التفويض لدى مزوّد الهوية مع prompt=none، وتنتظر أن يعيد ذلك الإطار إرسال نتيجة. الغاية كلها من الإطار أنه غير مرئي. لا يُفترض بك أن تراه أبدًا، وفي إعداد سليم لن تراه قطّ، لأنه يُحسَم في أجزاء من الألف من الثانية.

أمّا إطارنا فلم يُحسَم على الإطلاق.

الجدار الذي بنيناه بأنفسنا

يُحمِّل الإطار مضيف المصادقة. ومضيف المصادقة، كأي مضيف نشغّله ويجدر أن يُؤخذ على محمل الجدّ، يقدّم رأسين وظيفتهما كلها أن يقولا «لا يجوز لك وضعي داخل إطار»:

  • X-Frame-Options: DENY
  • Content-Security-Policy: frame-ancestors 'none'

هذه دفاعات ضد سرقة النقر (clickjacking)، وهي صحيحة. المهاجم الذي يستطيع وضع صفحة تسجيل الدخول لديك في إطار يستطيع أن يجعلها تطفو أسفل طُعمٍ خادع، ويخدع المستخدم كي يكتب بيانات اعتماد حقيقية في ما يبدو شيئًا غير مؤذٍ، ثم يحصدها. frame-ancestors 'none' هي التعليمة الحديثة التي تقضي بأنه لا يجوز لأي أصل (origin)، ولا حتى أصلنا نحن، أن يُضمِّن هذه الصفحة. لقد فعّلناها عن قصد. إنها بالضبط نوع الشيء الذي تبحث عنه مراجعة الأمان وتكافئه.

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

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

شيئان صحيحان، ووصلة واحدة سيّئة

ما جعل رؤية هذا صعبةً حقًّا هو أن لا شيء كان معطوبًا. رؤوس الأمان كانت صحيحة. آلية التجديد الصامت البديلة كانت صحيحة، فهي نمط OIDC مشروع وشائع الاستخدام. تصرّف كل مكوّن تمامًا كما صُمِّم وتمامًا كما يرغب أي مراجِع. لم يسكن التعليق ذو العشر ثوانٍ داخل أيٍّ منها. لقد سكن في الفراغ بين بعضها البعض، في الافتراض الذي بناه كلٌّ منها عن الآخر. افترضت مكتبة الدخول الموحّد (SSO) أنها تستطيع وضع مزوّد الهوية في إطار. وافترض مزوّد الهوية أنه لا ينبغي أبدًا السماح لأحد بوضعه في إطار. وكلا الافتراضين كان قابلًا للدفاع عنه. كانا ببساطة غير متوافقين، ولم يكن أي ملف بمفرده يحتوي على التناقض.

لماذا لجأ إلى الإطار أصلًا

وبقي سؤال. المسار السريع، أي تبادل رمز التحديث، كان سيتخطّى الإطار كليًّا. فلماذا كان المستخدمون العائدون يحطّون على المسار البطيء؟ لأنه لم يكن لديهم رمز تحديث يحملونه. ولم يكن لديهم رمز تحديث لأن عملاء OAuth لبوابتنا أنفسهم قد جرى تجهيزهم من دون AllowOfflineAccess، وهو العلامة التي تأذن بإصدار واحد للعميل. لا وصول دون اتصال، فلا رمز تحديث، فلا مسار سريع، وكل مستخدم عائد كان يُدفَع إلى الإطار الذي لا يمكن أن يُحمَّل أبدًا.

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

الإصلاح، والدرس

أصلحت خدمة المواءمة السبب. لكن تسجيل الدخول لا ينبغي أن يتعثّر لعشر ثوانٍ حتى عندما ينتهي به الأمر على المسار البطيء، لذا شدّدنا الوصلة أيضًا. صار renewSession() يفحص المستخدم المخزَّن أولًا: فإن لم يكن ثمة رمز تحديث في اليد، يقصُر الدائرة ويعيد لا شيء فورًا، ويتخطّى الإطار الذي يعلم مسبقًا أنه محكوم عليه بالفشل، ويرسل المستخدم مباشرةً إلى تسجيل الدخول التفاعلي. أمّا المسار السريع لرمز التحديث فلم يُمَسّ. وكحاجز احتياطي لأي تجديد يجري في الخلفية وما زال يفتح إطارًا، خفّضنا المهلة الزمنية من عشر ثوانٍ إلى خمس، حتى تكون أسوأ الحالات أخفّ وطأةً بمقدار النصف.

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

إن كنت تفضّل أن يكون تدفّق تسجيل الدخول لديك يعلم مسبقًا أن مضيف مصادقة مُحكَم الإغلاق وإطار تجديد صامت لا يمتزجان، فتلك وصلة اصطدمنا بها بالفعل كي لا تضطر أنت إلى ذلك أبدًا. تقدّم Authagonal سباكة الدخول الموحّد (SSO) ورؤوس الأمان كنظام واحد جرى اختباره معًا، لا كنصفين صحيحين تُتاح لك فرصة اكتشاف عدم توافقهما بمعدّل عشر ثوانٍ لكل تحميل صفحة.