كل يوم تضغط زراً مكتوباً عليه «الدخول بحساب Google» أو «Sign in with GitHub»، فتنتقل إلى صفحة Google وتكتب كلمة المرور هناك، ثم تعود إلى التطبيق وأنت داخل حسابك دون أن يعرف التطبيق كلمة مرورك، وإذا ثبتت authentik على سيرفرك كما في دليل تثبيت authentik للدخول الموحد فأنت تبني الشيء نفسه لفريقك، حيث يدخل الموظف مرة واحدة إلى authentik ثم يفتح Gitea وOutline وGrafana دون أن يكتب كلمة مرور جديدة. والذي يجعل هذا ممكناً بروتوكولان يعملان معاً: OAuth 2.0 للتفويض Authorization، وOpenID Connect واختصاراً OIDC لإثبات هوية المستخدم Authentication.
وفي أدلتنا العملية طلبنا منك أن تنسخ Client ID وClient Secret، وأن تكتب Redirect URI حرفاً بحرف، وأن تختار Signing Key، وأن تضيف النطاق offline_access، ومن الطبيعي أن تتساءل لماذا كل هذا، وما الذي يحدث فعلاً بين المتصفح والتطبيق ومزود الهوية. وهذا المقال يبدأ من قبل الأدلة بخطوة، أي من المشكلة نفسها، ولا نفترض فيه أنك تعرف أي شيء عن هذه البروتوكولات، وسوف نناقش في هذا المقال ما يلي:
- المشكلة التي جاء OAuth ليحلها، وكيف تطور من OAuth 1.0 إلى OAuth 2.1.
- الأطراف الأربعة والنطاقات Scopes والتوكنات Tokens، والفرق بين التفويض وتسجيل الدخول.
- تدفق Authorization Code خطوة خطوة مع PKCE والمعامل state ومطابقة redirect_uri، ثم بقية التدفقات وما انتهى زمنه منها.
- OpenID Connect والـ ID Token، وكيف تقرأ JWT وتتحقق من توقيعه، والاكتشاف Discovery وJWKS.
- كيف يظهر كل هذا في تطبيقاتك المستضافة ذاتياً، وما معنى كل حقل في شاشة الإعداد.
- تجربة عملية بمزود هوية صغير هو Dex مع curl، نمشي فيها التدفق كاملاً ونكسره عمداً لنرى الأخطاء.
- أين تحفظ التوكنات، ومدة صلاحيتها، وتسجيل الخروج، ثم قائمة بأفضل الممارسات والأخطاء الشائعة، ومقارنة قصيرة مع SAML وLDAP.
ما المشكلة التي جاء OAuth ليحلها؟
لنفرض أنك تستخدم خدمة لتخزين الصور، وظهر تطبيق لطباعة الصور يريد أن يقرأ ألبوماتك ليطبعها لك، فكيف يصل التطبيق إلى صورك؟ الحل الأول الذي استخدمته مواقع كثيرة في منتصف العقد الأول من هذا القرن كان بسيطاً جداً: يطلب منك التطبيق اسم المستخدم وكلمة المرور لحسابك في خدمة الصور، ثم يدخل بها نيابة عنك. وكانت مواقع التواصل في ذلك الوقت تطلب كلمة مرور بريدك الإلكتروني حتى تقرأ قائمة جهات الاتصال وتقترح عليك أصدقاء، وهذا النمط له اسم معروف هو نمط مشاركة كلمة المرور Password Anti-Pattern، وهو من أسوأ الطرق على الإطلاق للأسباب التالية:
- الصلاحية كاملة: التطبيق الذي يريد قراءة الصور يحمل الآن مفتاح حسابك كله، فيستطيع أن يقرأ بريدك ويحذف ملفاتك ويغير كلمة المرور نفسها، ولا توجد طريقة تقول بها «اقرأ الصور فقط».
- كلمة المرور في مكان لا تملكه: التطبيق يحتاج أن يحفظ كلمة مرورك كما هي حتى يستخدمها لاحقاً، أي أنها لا يمكن أن تحفظ كهاش Hash، فإذا اخترق سيرفر التطبيق خرجت كلمة مرورك معه، وإذا كنت تستخدمها في مواقع أخرى فقد خرجت تلك الحسابات أيضاً.
- لا يمكن سحب الصلاحية من تطبيق واحد: إذا أعطيت كلمة المرور لثلاثة تطبيقات وأردت إيقاف واحد منها، فالطريقة الوحيدة أن تغير كلمة المرور، وبالتالي تتوقف التطبيقات الثلاثة معاً.
- لا يعمل مع المصادقة الثنائية: إذا فعلت المصادقة الثنائية MFA على حسابك فالتطبيق لا يستطيع أن يدخل بكلمة المرور وحدها، لذلك كانت الخدمات تضطر إلى إنشاء «كلمات مرور للتطبيقات» App Passwords، وهي حل مؤقت لنفس المشكلة.
- لا تعرف الخدمة من يدخل: كل الطلبات تأتي باسمك، فلا تستطيع خدمة الصور أن تفرق بينك وبين تطبيق الطباعة، ولا أن تعرض لك قائمة بالتطبيقات التي تصل إلى حسابك.
وقد يتساءل البعض: لماذا لا نعطي التطبيق كلمة مرور ثانية خاصة به؟ والإجابة أن هذا هو بالضبط اتجاه الحل، ولكن بشكل منظم: أن يحصل كل تطبيق على مفتاح خاص به، محدود بما وافقت عليه، ولمدة محددة، وتستطيع أن تسحبه وحده، ودون أن يرى كلمة مرورك إطلاقاً. وهذا المفتاح هو التوكن Token، والصورة التالية تبين الفرق بين الحالتين:

من OAuth 1.0 إلى OAuth 2.1
بدأت فكرة OAuth في نوفمبر 2006 عندما كان مهندسو Twitter يعملون على دعم OpenID، واحتاجوا طريقة تسمح لتطبيقات أخرى بالوصول إلى حسابات المستخدمين دون كلمات مرورهم، كما يروي تاريخ المشروع في موقع oauth.net. وصدرت المواصفة الأولى OAuth Core 1.0 في 4 ديسمبر 2007، ثم أصبحت معياراً من IETF باسم RFC 5849 في أبريل 2010.
وكان OAuth 1.0 يطلب من التطبيق أن يوقع كل طلب بتوقيع تشفيري معقد، فكان من الصعب تطبيقه بشكل صحيح، ولا يناسب تطبيقات الجوال، لذلك صدر OAuth 2.0 في RFC 6749 في أكتوبر 2012 ومعه RFC 6750 الذي يشرح استخدام التوكن من نوع Bearer، وهو ليس تحديثاً للإصدار الأول وإنما إطار جديد لا يتوافق معه، يعتمد على HTTPS بدلاً من التوقيع في كل طلب. وفي فبراير 2014 أصدرت OpenID Foundation مواصفة OpenID Connect Core 1.0 التي تضيف طبقة الهوية فوق OAuth 2.0، وهي التي تستخدمها اليوم كل أزرار «الدخول بحساب…».
وبعد ذلك جاءت الإضافات التي سوف نمر عليها في هذا المقال: PKCE في RFC 7636 في سبتمبر 2015، وتدفق الأجهزة في RFC 8628 في أغسطس 2019، ثم أفضل الممارسات الحالية لأمان OAuth 2.0 في RFC 9700 في يناير 2025، وهي الوثيقة التي ألغت تدفقين قديمين وجعلت PKCE إلزامياً للتطبيقات العامة. أما OAuth 2.1 فما زال مسودة Internet-Draft حتى كتابة هذا المقال (الإصدار 16 بتاريخ 3 سبتمبر 2026)، وهو لا يضيف بروتوكولاً جديداً، وإنما يجمع OAuth 2.0 مع ما تعلمه الجميع خلال عشر سنوات في وثيقة واحدة، فيحذف ما ثبت خطره ويجعل الممارسات الآمنة هي الافتراضية.
OAuth تفويض وليس تسجيل دخول
أهم جملة في هذا المقال هي أن OAuth 2.0 بروتوكول للتفويض Authorization وليس لإثبات الهوية Authentication، أي أنه يجيب عن سؤال «ما الذي يسمح لهذا التطبيق أن يفعله؟» ولا يجيب عن سؤال «من هو المستخدم الذي أمامي؟». والمثال الذي يستخدمه الجميع لشرح الفكرة هو مفتاح الخادم Valet Key في بعض السيارات، فعندما تسلم سيارتك لموظف الفندق ليوقفها، تعطيه مفتاحاً خاصاً يشغل السيارة ويقودها مسافة قصيرة، ولكنه لا يفتح صندوق السيارة ولا درج القفازات، وتبقى أنت محتفظاً بالمفتاح الكامل. والتوكن في OAuth هو هذا المفتاح المحدود: يفتح أبواباً محددة لمدة محددة، ويمكن إلغاؤه دون أن تغير أقفال السيارة كلها.
وهذا الفرق ليس تفصيلاً نظرياً، لأن كثيراً من الثغرات التي ظهرت في السنوات الأولى جاءت من تطبيقات استخدمت OAuth وحده لتسجيل الدخول، كما سيأتي في قسم OpenID Connect.
الأطراف الأربعة في كل عملية OAuth
تعرف المواصفة أربعة أدوار Roles، وكل اسم تراه في شاشات الإعداد يعود إلى واحد منها:
- مالك المورد Resource Owner: أنت، صاحب البيانات الذي يملك أن يوافق أو يرفض.
- العميل Client: التطبيق الذي يريد الوصول، مثل تطبيق طباعة الصور، أو Gitea عندما يطلب هويتك من authentik. ولاحظ أن كلمة Client هنا تعني التطبيق وليس المتصفح، فقد يكون Client سيرفراً كاملاً.
- خادم التفويض Authorization Server: الجهة التي تتحقق من هويتك وتسألك هل توافق، ثم تصدر التوكنات، مثل authentik وKeycloak وحسابات Google. وفي OpenID Connect يسمى هذا الطرف مزود الهوية OpenID Provider واختصاراً OP، ويسمى التطبيق الطرف المعتمد Relying Party واختصاراً RP.
- خادم الموارد Resource Server: الـ API الذي يحمل البيانات، ولا يقبل أي طلب إلا ومعه توكن صالح.

وقد يكون خادم التفويض وخادم الموارد نظاماً واحداً، كما في Google التي تصدر التوكن وتملك Gmail في نفس الوقت، وقد يكونان منفصلين تماماً، كما في authentik أمام Gitea.
النطاقات Scopes وشاشة الموافقة Consent
النطاق Scope كلمة يطلب بها التطبيق صلاحية محددة، مثل photos:read أو repo في GitHub، أو openid email profile في OpenID Connect، وكل خدمة تعرف نطاقاتها بنفسها، ولا توجد قائمة عالمية إلا نطاقات OpenID Connect. وعندما يطلب التطبيق نطاقات معينة يعرض عليك خادم التفويض شاشة الموافقة Consent Screen، وفيها اسم التطبيق وما يطلبه، فإذا وافقت حمل التوكن هذه النطاقات فقط، ولذلك يجب أن يطلب التطبيق أقل ما يحتاجه، والسبب أن التوكن قد يسرق يوماً ما، وعندها يكون الضرر بحجم النطاقات التي يحملها.
وفي تطبيقاتك الداخلية قد تختار ألا تظهر شاشة الموافقة أصلاً، كما فعلنا في دليل ربط Gitea مع authentik عبر OIDC حين اخترنا تدفق implicit-consent، وهذا منطقي لأنك أنت من يملك التطبيق ومزود الهوية معاً، فلا معنى لأن يسأل الموظف كل صباح هل يسمح لـ Gitea بمعرفة بريده.
التوكنات: الكود والـ Access Token والـ Refresh Token
في OAuth ثلاثة أشياء تتنقل بين الأطراف، ومن المهم أن تعرف الفرق بينها:
- كود التفويض Authorization Code: قيمة قصيرة العمر تستخدم مرة واحدة فقط، يعطيها خادم التفويض للتطبيق عبر المتصفح، ولا تفتح أي شيء بنفسها، وإنما يستبدلها التطبيق بالتوكنات. وتوصي المواصفة بألا يزيد عمرها على 10 دقائق، وأغلب المزودين يجعلونها دقيقة واحدة أو أقل.
- توكن الوصول Access Token: المفتاح الذي يرسله التطبيق إلى الـ API مع كل طلب، وأغلبه من نوع Bearer أي «لحامله»، ومعنى ذلك كما في RFC 6750 أن أي أحد يحمل التوكن يستطيع استخدامه دون أن يثبت أي شيء آخر، تماماً مثل النقود في جيبك، لذلك يكون عمره قصيراً، من دقائق إلى ساعة.
- توكن التجديد Refresh Token: مفتاح أطول عمراً لا يذهب إلى الـ API أبداً، وإنما يرسله التطبيق إلى خادم التفويض فقط ليحصل على Access Token جديد عندما ينتهي القديم، دون أن يطلب منك تسجيل الدخول مرة أخرى.
وقد يتساءل البعض: لماذا لا يعطي خادم التفويض الـ Access Token مباشرة بدلاً من الكود؟ والإجابة في القسم التالي، لأنها مرتبطة بالطريق الذي يمر منه كل منهما.
القناة الأمامية والقناة الخلفية
تتحرك البيانات في OAuth في طريقين مختلفين تماماً من حيث الأمان:
- القناة الأمامية Front Channel: كل ما يمر عبر المتصفح، أي التحويلات Redirects والروابط وشريط العنوان، وهذا الطريق مكشوف بطبيعته، فالرابط يحفظ في تاريخ المتصفح History، ويظهر في سجلات Logs الـ Reverse Proxy، وقد يرسل في ترويسة Referer إلى موقع آخر، وقد تقرؤه إضافة مثبتة في المتصفح.
- القناة الخلفية Back Channel: اتصال مباشر من سيرفر التطبيق إلى سيرفر خادم التفويض عبر HTTPS، لا يمر بالمتصفح ولا يراه المستخدم.
ولهذا السبب يمر الكود فقط في القناة الأمامية، لأنه لا قيمة له وحده ويستخدم مرة واحدة، أما التوكنات فلا تخرج إلا في القناة الخلفية، وهذه الفكرة سوف تتكرر في كل ما يأتي.
التطبيق السري والتطبيق العام
تقسم المواصفة التطبيقات إلى نوعين بحسب قدرتها على حفظ سر:
- التطبيق السري Confidential Client: يعمل على سيرفر لا يصل إليه المستخدم، مثل Gitea وGrafana وOutline، فيستطيع أن يحفظ Client Secret في ملف إعداد أو متغير بيئة، ويثبت به هويته عند خادم التفويض.
- التطبيق العام Public Client: يعمل على جهاز المستخدم، مثل تطبيق جوال أو تطبيق سطح مكتب أو أداة سطر أوامر CLI أو تطبيق صفحة واحدة SPA يعمل كله في المتصفح، وأي سر تضعه في كوده يستطيع أي أحد أن يستخرجه، لذلك يعامل كأنه لا يملك سراً أصلاً.
تدفق Authorization Code خطوة خطوة
التدفق Flow (وتسميه المواصفة Grant) هو تسلسل الطلبات الذي يحصل به التطبيق على التوكن، وتدفق Authorization Code هو التدفق الأساسي الذي تستخدمه كل تطبيقات الويب والجوال اليوم، وهو الذي يعمل خلف كل زر «الدخول بحساب…». والصورة التالية تبينه كاملاً مع PKCE، ثم نشرح كل خطوة:

- تضغط «تسجيل الدخول» في التطبيق، فيولد التطبيق قيمة عشوائية طويلة اسمها
code_verifierيحفظها عنده في الجلسة، ويحسب منهاcode_challengeوهو هاش SHA-256 لها، ويولد أيضاً قيمة عشوائية للمعاملstate. - يحولك التطبيق إلى نقطة التفويض Authorization Endpoint في خادم التفويض، ومعك في الرابط:
response_type=codeوclient_idوredirect_uriوscopeوstateوcode_challenge. - تسجل الدخول في صفحة خادم التفويض نفسه، بكلمة المرور والمصادقة الثنائية، ثم توافق على النطاقات، ولاحظ أن كلمة المرور تكتب هنا فقط ولا يراها التطبيق أبداً.
- يعيدك خادم التفويض إلى
redirect_uriالمسجل، ومعك في الرابطcodeوقيمةstateنفسها، فيتأكد التطبيق أنstateهي التي أرسلها. - يرسل التطبيق من السيرفر مباشرة طلب POST إلى نقطة التوكن Token Endpoint، ومعه الكود والـ
code_verifierالأصلي، وسره Client Secret إذا كان تطبيقاً سرياً. - يحسب خادم التفويض هاش الـ
code_verifierويقارنه بالـcode_challengeالذي استلمه في الخطوة 2، ويتأكد أن الكود لم يستخدم من قبل ولم تنته صلاحيته، ثم يرد بالـ Access Token ومعه Refresh Token إذا طلبه التطبيق، وID Token إذا كان الطلب OpenID Connect. - يستدعي التطبيق الـ API ومعه الترويسة
Authorization: Bearerوالتوكن.
والآن نعود إلى السؤال السابق: لماذا كل هذا الدوران بدلاً من أن يرسل خادم التفويض التوكن في الخطوة 4 مباشرة؟ والسبب أن الخطوة 4 تمر في القناة الأمامية، فالرابط الذي يحمل الكود يظهر في شريط العنوان وفي السجلات، ولو كان يحمل التوكن نفسه لكان التوكن مكشوفاً في كل هذه الأماكن، أما الكود فلا يفيد من يراه لأنه يحتاج إلى الـ code_verifier والسر، وهما لم يغادرا السيرفر.
لماذا أصبح PKCE إلزامياً؟
لنفترض أن مخترقاً استطاع أن يرى الكود في الخطوة 4، سواء من سجلات الـ Reverse Proxy، أو من تطبيق خبيث على الجوال سجل نفس رابط العودة Custom URL Scheme الذي يستخدمه تطبيقك، وهي المشكلة التي صمم من أجلها PKCE في البداية. فما الذي يمنعه أن يستبدل الكود بالتوكنات بنفسه؟
- الحل الأول: سر التطبيق Client Secret. فالمخترق لا يملك السر، وبالتالي لا يستطيع أن يستبدل الكود. وهذا يعمل مع التطبيقات السرية، ولكنه لا يعمل مع تطبيق الجوال أو الـ SPA، لأن السر داخل الكود الذي يستطيع أي أحد أن يستخرجه، فهو ليس سراً أصلاً.
- الحل الصحيح: إثبات أن من بدأ الطلب هو من يكمله. وهذه فكرة PKCE (تنطق «بيكسي»)، واسمها الكامل Proof Key for Code Exchange، حيث يولد التطبيق قيمة عشوائية جديدة لكل عملية دخول هي الـ
code_verifier، ويرسل في البداية هاشها فقط وهو الـcode_challenge، ثم يرسل القيمة الأصلية في النهاية. والمخترق الذي رأى الرابط الأول رأى الهاش فقط، ولا يستطيع أن يعرف القيمة الأصلية منه، لأن الهاش دالة باتجاه واحد One-Way Function، والذي رأى الكود في الخطوة 4 لا يملك القيمة الأصلية أيضاً، وبالتالي لا يستطيع أي منهما أن يكمل الطلب.
وتعرف المواصفة طريقتين لحساب الـ code_challenge: الطريقة S256 وهي الهاش، والطريقة plain التي ترسل القيمة نفسها دون هاش، وهذه لا ينصح باستخدامها إطلاقاً، والسبب أن من يرى الطلب الأول يرى القيمة كاملة، لذلك ينص RFC 9700 على أن S256 هي الطريقة الوحيدة الآمنة حالياً، وحذفتها مسودة OAuth 2.1 من المواصفة تماماً.
وبدأ PKCE كحل لتطبيقات الجوال، ولكن RFC 9700 في القسم 2.1.1 جعله إلزامياً MUST لكل تطبيق عام، وموصى به RECOMMENDED للتطبيقات السرية أيضاً، والسبب أنه يحمي من هجوم حقن الكود Authorization Code Injection، حيث يضع المخترق كوداً مسروقاً في جلسة ضحية أخرى، وهذا الهجوم لا يمنعه سر التطبيق. وذهبت مسودة OAuth 2.1 خطوة أبعد، فجعلت استخدام code_challenge وcode_verifier واجباً على كل التطبيقات إلا في حالة واحدة، وهي التطبيق السري في OpenID Connect الذي يستخدم المعامل nonce بشكل صحيح، ومع ذلك تبقى توصيتها باستخدام PKCE في هذه الحالة أيضاً. والخلاصة العملية بسيطة: فعل PKCE في كل تطبيق تربطه بمزود الهوية، فلا يوجد سبب لتركه.
المعامل state وهجمات CSRF
هناك هجوم آخر يعمل في الاتجاه المعاكس، فبدلاً من أن يسرق المخترق كودك، يجعلك أنت تستخدم كوده. يبدأ المخترق عملية دخول بحسابه، ويوقفها عند الخطوة 4 ويأخذ الكود، ثم يرسل لك رابطاً مثل https://app.example.com/callback?code=كود_المخترق، فإذا فتحته يدخل التطبيق باسمك إلى حساب المخترق، وكل ما ترفعه بعد ذلك من ملفات أو بيانات بطاقات يذهب إلى حسابه. وهذا نوع من تزوير الطلبات عبر المواقع Cross-Site Request Forgery واختصاراً CSRF، ويشرحه القسم 10.12 من RFC 6749.
والحل أن يولد التطبيق في الخطوة 1 قيمة عشوائية لا تتكرر في المعامل state، ويربطها بجلسة المتصفح، ثم يرفض أي رد في الخطوة 4 لا يحمل نفس القيمة، والرابط الذي أرسله المخترق لا يحمل القيمة المحفوظة في جلستك، وبالتالي يرفضه التطبيق. ويجدر الإشارة هنا إلى أن RFC 9700 يسمح للتطبيق أن يعتمد على PKCE بدلاً من state في الحماية من CSRF إذا تأكد أن مزود الهوية يدعمه، وفي OpenID Connect يقوم المعامل nonce بنفس الدور، ولكن أغلب المكتبات ترسل الثلاثة معاً، ولا يوجد ضرر في ذلك.
لماذا يجب أن يطابق redirect_uri حرفاً بحرف؟
عنوان العودة Redirect URI هو المكان الذي يرسل إليه خادم التفويض الكود في الخطوة 4، وإذا استطاع المخترق أن يغيره إلى عنوان يملكه فإنه يستلم الكود مباشرة، لذلك يسجل كل تطبيق عناوين العودة عند مزود الهوية مسبقاً، ويرفض المزود أي طلب لا يطابق عنواناً مسجلاً. والمهم هنا هو كلمة «يطابق»، فقد كانت بعض المزودات تقبل أي عنوان يبدأ بالعنوان المسجل، أو تقبل نطاقاً فرعياً Subdomain بنجمة Wildcard، وظهرت هجمات كثيرة تستغل ذلك عبر صفحة تحويل مفتوحة Open Redirect في الموقع نفسه، لذلك ينص RFC 9700 على المقارنة الحرفية Exact String Matching، مع استثناء واحد هو رقم المنفذ في عناوين localhost لتطبيقات سطح المكتب.
ومعنى ذلك أن الفرق بين http وhttps، أو منفذ زائد مثل :3000، أو شرطة مائلة في الآخر، يكفي لرفض الطلب، وهذا أكثر خطأ سوف تقابله عندما يكون تطبيقك خلف Reverse Proxy، لأن التطبيق قد يبني عنوان العودة من عنوانه الداخلي، وقد شرحنا سببه وحله في قسم روابط العودة في OAuth وOIDC من دليل التطبيق خلف Reverse Proxy.
بقية التدفقات: متى تستخدم كل واحد؟
تدفق Authorization Code يفترض وجود مستخدم ومتصفح، ولكن هناك حالتان لا يتوفر فيهما ذلك: خدمة تعمل وحدها دون مستخدم، وجهاز فيه مستخدم ولكن ليس فيه متصفح أو لوحة مفاتيح مريحة، والصورة التالية تبين التدفقين:

Client Credentials: تطبيق يتصل بتطبيق
عندما تريد مهمة نسخ احتياطي أو خدمة مراقبة أن تستدعي API لا يوجد مستخدم يوافق، والتطبيق يعمل باسمه هو، فيرسل معرفه وسره مباشرة إلى نقطة التوكن مع grant_type=client_credentials، ويحصل على Access Token دون Refresh Token، لأنه يستطيع أن يطلب توكن جديداً متى شاء. وهذا التدفق يحل محل ما يفعله كثيرون حين يضعون مفتاح API ثابتاً لا تنتهي صلاحيته في ملف إعداد، فالتوكن هنا قصير العمر ومحدود بنطاقات، والمفتاح الطويل (السر) لا يرسل إلا إلى خادم التفويض. ويدعمه authentik كما في توثيق مزود OAuth2، والطلب يكون بالشكل التالي:
curl -s -u backup-job:"$CLIENT_SECRET" \
-d grant_type=client_credentials \
-d scope=monitoring:read \
https://auth.example.com/application/o/token/ولاحظ أن الطلب لا يحمل اسم مستخدم ولا كلمة مرور لأحد، وأن التوكن الذي يعود منه لا يمثل أي شخص، لذلك لا تستخدمه لتسجيل دخول مستخدم أبداً.
Device Authorization: تلفاز أو أداة سطر أوامر
لنفترض أنك تريد تسجيل الدخول في تطبيق على التلفاز، أو في أداة سطر أوامر على سيرفر تتصل به عبر SSH، فكتابة البريد وكلمة المرور بجهاز التحكم عملية مزعجة، والأداة على السيرفر لا تملك متصفحاً أصلاً. والحل في RFC 8628 أن يطلب الجهاز من خادم التفويض كوداً قصيراً للمستخدم User Code ورابطاً، فيعرضهما على الشاشة، ثم تفتح الرابط من جوالك أو حاسوبك وتكتب الكود وتسجل الدخول هناك بكل وسائلك المعتادة، وفي الأثناء يسأل الجهاز نقطة التوكن كل بضع ثوان: هل وافق المستخدم؟ فيرد خادم التفويض بالخطأ authorization_pending حتى توافق، ثم يرد بالتوكنات. وهذا ما تفعله أدوات مثل GitHub CLI وAzure CLI عندما تطلب منك فتح رابط وكتابة كود، وسوف نجربه بأنفسنا في القسم العملي.
تدفق Refresh Token
عندما تنتهي صلاحية الـ Access Token يرسل التطبيق الـ Refresh Token إلى نقطة التوكن مع grant_type=refresh_token، فيحصل على Access Token جديد دون أن يرى المستخدم أي شيء. ولاحظ أن authentik منذ الإصدار 2024.2 لا يصدر Refresh Token إلا إذا طلب التطبيق النطاق offline_access، وهذا ما ذكرناه في دليل Gitea عن مهمة مزامنة المستخدمين.
تدفقان انتهى زمنهما: Implicit وPassword
في OAuth 2.0 الأصلي تدفقان لا ينصح باستخدامهما اليوم، وسوف تجدهما في تطبيقات قديمة وشروحات قديمة، لذلك من المهم أن تعرف لماذا ألغيا:
- التدفق الضمني Implicit Grant: صمم في 2012 لتطبيقات المتصفح التي لم تكن تستطيع إرسال طلبات إلى نطاق آخر قبل انتشار CORS، فكان خادم التفويض يرسل الـ Access Token مباشرة في رابط العودة (
response_type=token)، أي في القناة الأمامية، فيظهر التوكن في تاريخ المتصفح والسجلات وقد يتسرب عبر Referer، ولا توجد طريقة لربطه بالتطبيق الذي طلبه. لذلك ينص RFC 9700 على ألا تستخدمه التطبيقات SHOULD NOT، وحذفته مسودة OAuth 2.1 تماماً، والبديل لتطبيقات المتصفح هو Authorization Code مع PKCE. - تدفق كلمة المرور Resource Owner Password Credentials: يأخذ التطبيق اسم المستخدم وكلمة المرور ويرسلهما إلى نقطة التوكن، أي أنه يعيدنا إلى نمط مشاركة كلمة المرور الذي جاء OAuth ليلغيه، ولا يعمل مع المصادقة الثنائية ولا مع مفاتيح المرور Passkeys، ويعود المستخدمين على كتابة كلمة مرورهم في أي مكان يطلبها. لذلك ينص RFC 9700 على أنه MUST NOT be used، أي لا يستخدم إطلاقاً.
OpenID Connect: من يكون المستخدم؟
بعد انتشار OAuth 2.0 بدأت تطبيقات كثيرة تستخدمه لتسجيل الدخول بطريقة تبدو منطقية: يحصل التطبيق على Access Token، ثم يستدعي API مثل /me ليعرف من صاحب الحساب، فيسجل دخوله. والمشكلة أن الـ Access Token صمم ليقول للـ API «هذا التطبيق مسموح له أن يقرأ»، ولم يصمم ليقول للتطبيق «هذا المستخدم دخل إليك الآن»، فهو لا يحمل اسم التطبيق الذي صدر من أجله، ولا وقت الدخول، ولا أي رابط بالجلسة التي بدأت في متصفحك. وبالتالي إذا حصل مخترق على Access Token صادر لتطبيق آخر يملكه هو، لمستخدم دخل إلى تطبيقه يوماً ما، يستطيع أن يقدمه لتطبيقك فيعتقد تطبيقك أن هذا المستخدم هو من سجل الدخول، وهذا ما يشرحه مقال End User Authentication with OAuth 2.0 في موقع oauth.net بالتفصيل.
والحل الذي جاء به OpenID Connect أن يضيف إلى OAuth 2.0 شيئين: النطاق openid الذي يطلب به التطبيق إثبات الهوية، وتوكن جديد اسمه ID Token موجه للتطبيق نفسه وموقع من مزود الهوية، ويقول بوضوح: هذا المستخدم، دخل في هذا الوقت، وهذا التوكن صادر لهذا التطبيق بالذات. ولاحظ أن OpenID Connect لا يغير شيئاً في التدفق الذي شرحناه، فهو نفس Authorization Code مع PKCE، والفرق أن الطلب يحمل scope=openid والرد يحمل id_token بجانب الـ Access Token.
الفرق بين الـ ID Token والـ Access Token
الخلط بين التوكنين هو مصدر أغلب الأخطاء في هذا المجال، والصورة التالية تبين لمن يذهب كل منهما:

- الـ ID Token للتطبيق: يقرؤه التطبيق ويتحقق منه مرة واحدة عند الدخول، ثم ينشئ جلسته الخاصة، والحقل
audفيه هو معرف التطبيق Client ID. فلا تقم بإرساله إلى أي API كأنه مفتاح، والسبب أنه لم يصدر من أجل الـ API ولا يحمل أي صلاحيات. - الـ Access Token للـ API: التطبيق يحمله فقط ولا يجب أن يعتمد على محتواه، وقد يكون JWT يستطيع الـ API قراءته، وقد يكون قيمة عشوائية لا معنى لها إلا عند مزود الهوية، فلا تقم باستخدامه كدليل على أن المستخدم سجل الدخول إلى تطبيقك.
كيف تقرأ JWT؟
الـ ID Token دائماً بصيغة JSON Web Token واختصاراً JWT (تنطق «جوت»)، وهي معرفة في RFC 7519، وتتكون من ثلاثة أجزاء بينها نقطتان: header.payload.signature، وكل جزء مرمز بترميز Base64url وهو نفس Base64 مع استبدال الرمزين + و/ بالرمزين - و_ وحذف علامات = في الآخر، حتى يصلح التوكن داخل الروابط. وقد شرحنا فكرة JWT من الأساس، والفرق بينه وبين الجلسة على السيرفر وملفات تعريف الارتباط، في مقال الجلسات وملفات تعريف الارتباط وJWT للمبتدئين، وهنا نركز على ما يخص OpenID Connect. والصورة التالية تبين الأجزاء الثلاثة لتوكن حقيقي أصدرناه في التجربة العملية:

- الترويسة Header: تقول كيف وقع التوكن، فالحقل
algهو خوارزمية التوقيع مثلRS256، والحقلkidهو معرف المفتاح الذي استخدم، حتى يعرف التطبيق أي مفتاح عام يستخدم في التحقق. - المحتوى Payload: الحقول Claims التي يقولها التوكن عن المستخدم، وسوف نشرحها بعد قليل.
- التوقيع Signature: يأخذ مزود الهوية الجزأين الأولين كما هما مع النقطة بينهما، ويوقعهما بمفتاحه الخاص Private Key، أي أنه يحسب هاش النص ثم يشفر الهاش بالمفتاح الخاص، وأي تطبيق يملك المفتاح العام Public Key يستطيع أن يحسب الهاش من جديد ويقارنه بما في التوقيع.
وهنا أهم ملاحظة في هذا القسم: Base64url ترميز Encoding وليس تشفيراً Encryption، فأي أحد يحمل التوكن يستطيع أن يقرأ محتواه كما سوف نفعل في التجربة العملية بأمر واحد، لذلك لا تقم بوضع أي معلومة سرية في محتوى JWT. والتوقيع لا يخفي المحتوى، وإنما يضمن شيئين: أن من أصدره هو صاحب المفتاح الخاص، وأن أحداً لم يغير حرفاً واحداً بعد ذلك، فإذا غير المخترق البريد في المحتوى من alice إلى admin فإن الهاش يتغير، ولا يستطيع أن يحسب توقيعاً جديداً لأنه لا يملك المفتاح الخاص، وبالتالي يفشل التحقق.
وقد يتساءل البعض: لماذا لا يوقع مزود الهوية بسر مشترك بدلاً من زوج المفاتيح؟ والإجابة أن هذا ممكن بالخوارزمية HS256، وهي HMAC بسر واحد يعرفه المزود والتطبيق، ولكن كل من يستطيع التحقق يستطيع التوقيع أيضاً، فإذا كان لديك عشرة تطبيقات تعرف السر فأي واحد منها يستطيع أن يصنع توكن باسم أي مستخدم، أما مع RS256 أو ES256 فالمفتاح الخاص لا يغادر مزود الهوية، والتطبيقات تملك المفتاح العام فقط، لذلك اخترنا في دليل Gitea مفتاح توقيع Signing Key في authentik بدلاً من تركه فارغاً.
الحقول القياسية Claims
تعرف مواصفة OpenID Connect Core مجموعة حقول في الـ ID Token، وأهمها:
| الحقل | معناه | ما الذي يفعله التطبيق به |
|---|---|---|
iss | المصدر Issuer: عنوان مزود الهوية | يجب أن يطابق عنوان المزود الذي يثق به التطبيق حرفاً بحرف |
sub | الموضوع Subject: معرف المستخدم الثابت داخل هذا المزود | هو المفتاح الذي يربط به التطبيق حساب المستخدم، وليس البريد، لأن البريد قد يتغير |
aud | الجمهور Audience: لمن صدر التوكن | يجب أن يحتوي Client ID الخاص بالتطبيق، وإلا فالتوكن صدر لتطبيق آخر |
exp | وقت انتهاء الصلاحية Expiration | يرفض التوكن بعده، بفارق صغير لاختلاف الساعات Clock Skew |
iat | وقت الإصدار Issued At | يرفض التوكن الذي صدر في وقت بعيد جداً |
nonce | قيمة عشوائية أرسلها التطبيق في طلب الدخول | يجب أن تطابق القيمة المحفوظة في الجلسة، فتمنع إعادة استخدام توكن قديم Replay |
email وemail_verified وname | معلومات الملف الشخصي، حسب النطاقات email وprofile | للعرض وللبحث عن الحساب أول مرة، مع التأكد أن email_verified صحيحة |
نقطة UserInfo
بجانب الـ ID Token يوفر مزود الهوية نقطة اسمها UserInfo Endpoint، يستدعيها التطبيق بالـ Access Token فترد بمعلومات المستخدم في JSON، وهي مفيدة عندما يكون الـ ID Token صغيراً ولا يحمل كل الحقول، أو عندما يريد التطبيق معلومات محدثة بعد الدخول. وبعض التطبيقات مثل Gitea تعتمد عليها لجلب الاسم والبريد والمجموعات Groups.
الاكتشاف Discovery: عنوان واحد يكفي
حتى يعمل التطبيق مع مزود الهوية يحتاج أن يعرف عناوين كثيرة: نقطة التفويض، ونقطة التوكن، ونقطة UserInfo، ومكان المفاتيح العامة، والخوارزميات المدعومة، وبدلاً من أن تنسخها واحداً واحداً تعرف مواصفة OpenID Connect Discovery عنواناً ثابتاً تحت عنوان المزود هو /.well-known/openid-configuration، يرد بملف JSON فيه كل شيء، ولذلك تكتفي أغلب التطبيقات بحقل واحد اسمه Issuer URL أو Discovery URL. والمثال التالي يطلب هذا الملف من Google:
curl -s https://accounts.google.com/.well-known/openid-configurationوالمخرج سوف يكون كما يلي:
{
"issuer": "https://accounts.google.com",
"authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
"device_authorization_endpoint": "https://oauth2.googleapis.com/device/code",
"token_endpoint": "https://oauth2.googleapis.com/token",
"userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
"revocation_endpoint": "https://oauth2.googleapis.com/revoke",
"jwks_uri": "https://www.googleapis.com/oauth2/v3/certs",
"response_types_supported": [
"code",
"token",
"id_token",
"code token",
"code id_token",
"token id_token",
"code token id_token",
"none"
],
"response_modes_supported": [
"query",
"fragment",
"form_post"
],
"subject_types_supported": [
"public"
],
"id_token_signing_alg_values_supported": [
"RS256"
],
"scopes_supported": [
"openid",
"email",
"profile"
],
"token_endpoint_auth_methods_supported": [
"client_secret_post",
"client_secret_basic"
],
"claims_supported": [
"aud",
"email",
"email_verified",
"exp",
"family_name",
"given_name",
"iat",
"iss",
"name",
"picture",
"sub"
],
"code_challenge_methods_supported": [
"plain",
"S256"
],
"grant_types_supported": [
"authorization_code",
"refresh_token",
"urn:ietf:params:oauth:grant-type:device_code",
"urn:ietf:params:oauth:grant-type:jwt-bearer"
],
"authorization_response_iss_parameter_supported": true
}في المخرج أعلاه لاحظ التالي:
- القيمة
issuerهي التي يجب أن تجدها حرفياً في الحقلissلكل ID Token يصدره المزود، وهذا ما يفحصه Gitea حين يظهر الخطأ «issuer in token does not match» الذي شرحناه في دليل Gitea. - القيمة
jwks_uriهي عنوان المفاتيح العامة التي يتحقق بها التطبيق من التوقيع. - القائمة
code_challenge_methods_supportedتبين أن المزود يدعم PKCE، والقائمةgrant_types_supportedتبين التدفقات المسموحة، وهكذا تعرف ما يدعمه أي مزود قبل أن تكتب سطراً واحداً. - ما زالت Google تعلن
tokenضمنresponse_types_supported، أي أن التدفق الضمني ما زال متاحاً عندها للتطبيقات القديمة، فلا تقم باستخدامه في أي تطبيق جديد، والسبب في قسم التدفقات التي انتهى زمنها.
التحقق من التوقيع عبر JWKS
الملف الذي يشير إليه jwks_uri بصيغة JSON Web Key Set واختصاراً JWKS، المعرفة في RFC 7517، وفيه قائمة بالمفاتيح العامة لمزود الهوية، ولكل مفتاح معرف kid. وعندما يستلم التطبيق ID Token يقرأ kid من الترويسة، ويختار المفتاح المطابق من القائمة، ثم يتحقق من التوقيع، وبعد ذلك يفحص iss وaud وexp وnonce كما في قسم التحقق من الـ ID Token في المواصفة. ووجود عدة مفاتيح في القائمة له سبب، فمزود الهوية يغير مفاتيحه دورياً Key Rotation، فينشر المفتاح الجديد قبل أن يستخدمه ويبقي القديم مدة حتى تنتهي التوكنات الموقعة به، لذلك لا تقم بنسخ المفتاح العام إلى إعداد التطبيق يدوياً، وإنما اجعله يقرأ JWKS ويحفظها مدة قصيرة Cache.
كيف يبدو هذا في تطبيقاتك المستضافة ذاتياً؟
الآن نضع ما سبق على سيرفرك: authentik أو Keycloak أو أي مزود هوية آخر هو خادم التفويض ومزود الهوية في نفس الوقت، وكل تطبيق تربطه به (Gitea وOutline وGrafana وغيرها) هو Client من النوع السري، يستخدم تدفق Authorization Code ومعه OpenID Connect ليعرف من المستخدم، ثم ينشئ جلسته الخاصة. والذي يجعلك لا تكتب كلمة المرور إلا مرة واحدة هو جلسة مزود الهوية نفسه، فعندما يحولك التطبيق الثاني إلى authentik يجد authentik أنك سجلت الدخول قبل ساعة، فيعيدك مباشرة بكود جديد دون أي سؤال، وهذا هو الدخول الموحد Single Sign-On واختصاراً SSO:

ولهذا التصميم فائدة أكبر من راحة المستخدم، فعندما يغادر موظف تعطل حسابه في مكان واحد، فلا يستطيع أن يسجل الدخول إلى أي تطبيق بعد ذلك، ولكن تذكر أن الجلسات القائمة في التطبيقات والتوكنات التي أصدرها كل تطبيق بنفسه تبقى حتى تنتهي، كما شرحنا في الملاحظات الأمنية في دليل Gitea. أما إنشاء الحسابات وحذفها في التطبيقات تلقائياً عند إضافة الموظف أو مغادرته فهذا موضوع بروتوكول آخر شرحناه في ما هو SCIM.
وفي دليل تسجيل الدخول بحساب Google في authentik تنعكس الأدوار، فيصبح authentik نفسه Client عند Google، وGoogle هي مزود الهوية، ثم يصبح authentik مزود الهوية لتطبيقاتك، أي أن نفس البروتوكول يعمل في السلسلة كلها. وإذا كان لديك تطبيق لا يدعم OIDC أصلاً، فلا يزال بإمكانك أن تضعه خلف authentik عبر الـ Reverse Proxy كما في دليل حماية أي تطبيق باستخدام authentik Forward Auth، وهناك يقوم الـ outpost بدور الـ Client نيابة عن التطبيق.
ماذا تعني الحقول في شاشة الإعداد؟
كل تطبيق يسمي الحقول بطريقته، ولكنها تعود إلى نفس المفاهيم، والجدول التالي يجمعها:
| الحقل | معناه | أين تجده |
|---|---|---|
| Client ID | اسم التطبيق عند مزود الهوية، وليس سراً، ويظهر في الروابط وفي aud | ينشئه المزود، وتنسخه إلى التطبيق |
| Client Secret | كلمة مرور التطبيق نفسه، يرسلها في القناة الخلفية فقط | ينشئه المزود، ويحفظ في ملف .env أو متغير بيئة، ولا يكتب في Git أبداً |
| Client Type: Confidential / Public | هل يستطيع التطبيق حفظ سر؟ | Confidential لكل تطبيق ويب يعمل على سيرفر |
| Redirect URI / Callback URL | العنوان الذي يعود إليه الكود، ويقارن حرفاً بحرف | مسار ثابت في كل تطبيق، مثل /user/oauth2/authentik/callback في Gitea و/auth/oidc.callback في Outline و/login/generic_oauth في Grafana |
| Scopes | ما يطلبه التطبيق، وأقلها openid email profile | في التطبيق، ويجب أن يسمح بها المزود |
| Issuer / Discovery URL | عنوان المزود الذي يجلب منه التطبيق كل العناوين الأخرى | صفحة المزود في authentik، مثل https://auth.example.com/application/o/gitea/ |
| Signing Key | المفتاح الذي يوقع به المزود الـ ID Token | في المزود، واختر مفتاحاً غير متماثل حتى يكون التوقيع RS256 |
| PKCE | تفعيل code_challenge | خيار في التطبيق، مثل use_pkce في إعداد Generic OAuth في Grafana |
وقد شرحنا تفاصيل هذه الحقول في authentik وGitea خطوة خطوة في دليل ربط Gitea مع authentik عبر OIDC، ومع Outline في دليل تثبيت Outline.
تجربة عملية: نمشي التدفق بأنفسنا مع Dex وcurl
كل ما سبق يبقى نظرياً حتى ترى الطلبات والردود بعينك، لذلك سوف نشغل مزود هوية صغيراً على جهازك، ونقوم بدور التطبيق بأنفسنا باستخدام curl، فنولد قيم PKCE ونبني رابط التفويض ونستبدل الكود ونقرأ التوكن ونتحقق من توقيعه. ولم نستخدم authentik هنا لأنه يحتاج قاعدة بيانات وعدة Containers، واخترنا Dex، وهو مزود OpenID Connect مفتوح المصدر يعمل في Container واحد بملف إعداد واحد، والإصدار المستخدم هو v2.45.1. وتحتاج على جهازك Docker مع Compose، وcurl وopenssl، وNode.js لخطوة التحقق من التوقيع فقط.
تشغيل مزود الهوية
أنشئ مجلداً باسم lab-oidc وضع فيه الملف compose.yaml التالي، ولاحظ أن المنفذ مربوط بالعنوان 127.0.0.1 فقط حتى لا يصل إليه أحد من الشبكة:
name: lab-oidc
services:
dex:
image: ghcr.io/dexidp/dex:v2.45.1
container_name: lab-oidc-dex
command: ["dex", "serve", "/etc/dex/config.yaml"]
volumes:
- ./dex.yaml:/etc/dex/config.yaml:ro
ports:
- "127.0.0.1:18556:5556"ثم ملف إعداد Dex باسم dex.yaml في نفس المجلد:
issuer: http://127.0.0.1:18556/dex
storage:
type: memory
web:
http: 0.0.0.0:5556
expiry:
idTokens: "1h"
refreshTokens:
disableRotation: false
oauth2:
responseTypes: ["code"]
skipApprovalScreen: true
staticClients:
- id: notes-app
name: Notes App
secret: lab-only-secret-change-me
redirectURIs:
- http://127.0.0.1:18080/callback
- id: cli-tool
name: CLI Tool
public: true
redirectURIs:
- /device/callback
enablePasswordDB: true
staticPasswords:
- email: [email protected]
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
username: alice
name: Alice
userID: 08a8684b-db88-4b73-90a9-3cd1661f5466في الإعداد أعلاه لاحظ التالي:
issuerهو العنوان الذي سوف يظهر في الحقلissلكل توكن، وتحته عنوان الاكتشاف.storage: memoryيعني أن كل شيء يضيع عند إعادة التشغيل، وهذا مناسب للتجربة فقط.responseTypes: ["code"]يسمح بتدفق Authorization Code وحده، فلا يوجد تدفق ضمني.skipApprovalScreen: trueيلغي شاشة الموافقة، كما نفعل مع التطبيقات الداخلية.- التطبيق
notes-appتطبيق سري له سر وعنوان عودة واحد، والتطبيقcli-toolتطبيق عام دون سر، سوف نستخدمه في تدفق الأجهزة. - المستخدم
[email protected]وكلمة مرورهpassword، والهاش هو المثال الموجود في توثيق Dex نفسه، والسرlab-only-secret-change-meللتجربة فقط، فلا تقم باستخدام أي منهما خارج جهازك.
الآن نقوم بتشغيله ونقرأ السجل:
docker compose up -d
docker logs lab-oidc-dex 2>&1 | tail -4والمخرج سوف يكون كما يلي:
time=2026-10-05T20:00:55.662Z level=INFO msg="config refresh tokens rotation" enabled=true
time=2026-10-05T20:00:55.663Z level=INFO msg="keys expired, rotating"
time=2026-10-05T20:00:55.701Z level=INFO msg="keys rotated" next_rotation=2026-10-06T02:00:55.701Z
time=2026-10-05T20:00:55.701Z level=INFO msg="listening on" server=http address=0.0.0.0:5556ولاحظ السطرين الأوسطين، فقد ولد Dex زوج مفاتيح التوقيع عند التشغيل، وسوف يغيره كل 6 ساعات، وهذا هو تغيير المفاتيح الدوري الذي ذكرناه في قسم JWKS. والآن نطلب ملف الاكتشاف كما فعلنا مع Google:
curl -s http://127.0.0.1:18556/dex/.well-known/openid-configuration{
"issuer": "http://127.0.0.1:18556/dex",
"authorization_endpoint": "http://127.0.0.1:18556/dex/auth",
"token_endpoint": "http://127.0.0.1:18556/dex/token",
"jwks_uri": "http://127.0.0.1:18556/dex/keys",
"userinfo_endpoint": "http://127.0.0.1:18556/dex/userinfo",
"device_authorization_endpoint": "http://127.0.0.1:18556/dex/device/code",
"introspection_endpoint": "http://127.0.0.1:18556/dex/token/introspect",
"grant_types_supported": [
"authorization_code",
"refresh_token",
"urn:ietf:params:oauth:grant-type:device_code",
"urn:ietf:params:oauth:grant-type:token-exchange"
],
"response_types_supported": [
"code"
],
"subject_types_supported": [
"public"
],
"id_token_signing_alg_values_supported": [
"RS256"
],
"code_challenge_methods_supported": [
"S256",
"plain"
],
"scopes_supported": [
"openid",
"email",
"groups",
"profile",
"offline_access"
],
"token_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post"
],
"claims_supported": [
"iss",
"sub",
"aud",
"iat",
"exp",
"email",
"email_verified",
"locale",
"name",
"preferred_username",
"at_hash"
]
}ولاحظ أن grant_types_supported لا تحتوي client_credentials، أي أن Dex بهذا الإصدار لا يدعم تدفق Client Credentials، وهذا مثال عملي على فائدة ملف الاكتشاف، فقد عرفنا ذلك قبل أن نرسل أي طلب.
الخطوة 1: نولد قيم PKCE وstate وnonce
نقوم الآن بما يفعله التطبيق عند الضغط على زر الدخول، فنولد code_verifier عشوائياً بترميز Base64url، ثم نحسب منه code_challenge بالهاش SHA-256، ونولد state وnonce:
CODE_VERIFIER=$(openssl rand -base64 48 | tr '+/' '-_' | tr -d '=\n')
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')
STATE=$(openssl rand -hex 16)
NONCE=$(openssl rand -hex 16)
echo "verifier: $CODE_VERIFIER"
echo "challenge: $CODE_CHALLENGE"
echo "state: $STATE"
echo "nonce: $NONCE"والمخرج سوف يكون كما يلي، وستختلف القيم عندك في كل مرة:
verifier: hlevgkxvzBX8MBsyhDO5uPqn_njc8BGblgndF1iF88Abi1KuYibeF_nZhfxlD_Q5
challenge: bHo9Yc1ShfHSoIXnqoFZ8FKFO-lF26FESrnkRS8SKCs
state: 017136e38297e84b0efa637753f5ae0e
nonce: 83750df7dfd68a031597b6a8ae4445f8ولاحظ أن الـ verifier طوله 64 حرفاً، والمواصفة تشترط بين 43 و128 حرفاً، وأن الـ challenge هو الشيء الوحيد من الاثنين الذي سوف يخرج في الرابط.
الخطوتان 2 و3: رابط التفويض وتسجيل الدخول
نبني رابط التفويض بنفس المعاملات التي شرحناها في الخطوة 2 من التدفق:
echo "http://127.0.0.1:18556/dex/auth?response_type=code&client_id=notes-app&redirect_uri=http%3A%2F%2F127.0.0.1%3A18080%2Fcallback&scope=openid%20email%20profile%20offline_access&state=$STATE&nonce=$NONCE&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256"افتح الرابط الناتج في المتصفح، فتظهر صفحة الدخول الخاصة بمزود الهوية، ولاحظ أنها صفحة Dex وليست صفحة التطبيق، وهنا فقط تكتب كلمة المرور:

الخطوة 4: العودة ومعها الكود
بعد الدخول يحولك Dex إلى عنوان العودة، ولأنه لا يوجد تطبيق يعمل على المنفذ 18080 فسوف يعرض المتصفح صفحة خطأ في الاتصال، وهذا متوقع، فالمهم هو الرابط في شريط العنوان، وكان عندنا كما يلي:
http://127.0.0.1:18080/callback?code=olzxfzy2dlxlwuvnn3awdcuck&state=017136e38297e84b0efa637753f5ae0eولاحظ أن قيمة state في الرابط هي نفسها التي ولدناها، وهذا ما يفحصه التطبيق قبل أي شيء آخر. وانسخ قيمة الكود إلى متغير، ولاحظ أن أمامك دقائق قليلة قبل أن تنتهي صلاحيته:
CODE=olzxfzy2dlxlwuvnn3awdcuckالخطوتان 5 و6: استبدال الكود بالتوكنات
قبل أن نرسل الطلب الصحيح، لنفترض أن مخترقاً رأى هذا الرابط في سجلات الـ Reverse Proxy، وهو يعرف أيضاً سر التطبيق، ولكنه لا يملك الـ code_verifier، فيرسل الكود مع قيمة من عنده:
curl -s -u notes-app:lab-only-secret-change-me \
-d grant_type=authorization_code \
-d code=$CODE \
-d redirect_uri=http://127.0.0.1:18080/callback \
-d code_verifier=wrong-verifier-from-an-attacker-0000000000000000 \
http://127.0.0.1:18556/dex/token{"error":"invalid_grant","error_description":"Invalid code_verifier."}رفض Dex الطلب رغم أن الكود والسر صحيحان، وهذه هي الحماية التي يضيفها PKCE. والآن يرسل التطبيق الحقيقي الطلب بالـ verifier الأصلي، ونحفظ الرد في ملف لنستخدمه بعد ذلك:
curl -s -u notes-app:lab-only-secret-change-me \
-d grant_type=authorization_code \
-d code=$CODE \
-d redirect_uri=http://127.0.0.1:18080/callback \
-d code_verifier=$CODE_VERIFIER \
http://127.0.0.1:18556/dex/token | tee token.jsonوالمخرج سوف يكون كما يلي، وقد اختصرنا التوكنات الطويلة بعلامة «…»:
{"access_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6ImE1MTU0YmRi…","token_type":"bearer","expires_in":3599,"refresh_token":"Chl2ZTJsdnJwYWhudWhuZWw0M21zMmZzdHV2Ehll…","id_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6ImE1MTU0YmRi…"}في المخرج أعلاه لاحظ التالي:
token_typeقيمتهbearer، أي أن من يحمل الـ Access Token يستطيع استخدامه.expires_inهي 3599 ثانية، أي ساعة كما ضبطناها فيexpiry.- وصل
refresh_tokenلأننا طلبنا النطاقoffline_access، ووصلid_tokenلأننا طلبناopenid.
والآن نرسل نفس الطلب مرة ثانية بنفس الكود، كما لو أن المخترق استطاع أن ينسخ الطلب كاملاً بعد أن أرسله التطبيق:
{"error":"invalid_grant","error_description":"Invalid or expired code parameter."}فالكود يستخدم مرة واحدة فقط، وبعد ذلك يرفضه المزود. ويبقى الخطأ الثالث الذي سوف تقابله كثيراً وهو عنوان العودة، فإذا غيرت redirect_uri في رابط التفويض إلى أي عنوان آخر، ولو بإضافة شرطة مائلة في آخره (/callback/)، فلن يحولك Dex إلى أي مكان، وإنما يعرض صفحة خطأ فيها:
Bad Request
Unregistered redirect_uri.ولاحظ أن المزود لا يعيدك إلى العنوان الخاطئ ليخبر التطبيق بالخطأ، والسبب أنه لا يستطيع أن يثق بعنوان غير مسجل، فلو أرسل إليه أي شيء لكان قد أرسله إلى المخترق.
نقرأ الـ ID Token
نستخرج الـ ID Token من الملف، ونفك الجزأين الأولين بدالة صغيرة تعيد علامات الحشو Padding التي يحذفها Base64url ثم تفك الترميز:
ID_TOKEN=$(sed -E 's/.*"id_token":"([^"]+)".*/\1/' token.json)
jwt_part() { echo "$1" | cut -d. -f"$2" | tr '_-' '/+' | awk '{while (length($0)%4) $0=$0"="; print}' | base64 -d; echo; }
jwt_part "$ID_TOKEN" 1
jwt_part "$ID_TOKEN" 2والمخرج سوف يكون كما يلي:
{"alg":"RS256","kid":"a5154bdbe8a5f9b797c90ecf1609931e4a4dfb5b"}
{"iss":"http://127.0.0.1:18556/dex","sub":"CiQwOGE4Njg0Yi1kYjg4LTRiNzMtOTBhOS0zY2QxNjYxZjU0NjYSBWxvY2Fs","aud":"notes-app","exp":1791234129,"iat":1791230529,"nonce":"83750df7dfd68a031597b6a8ae4445f8","at_hash":"dGSWogvUBu2ctFiJfP-_YA","c_hash":"s3s0vRcXvKGu3DAq3d04cA","email":"[email protected]","email_verified":true,"name":"Alice"}في المخرج أعلاه لاحظ التالي:
- لم نحتج أي مفتاح لقراءة المحتوى، وهذا ما قصدناه بأن Base64url ترميز وليس تشفيراً.
nonceهي نفس القيمة التي ولدناها في الخطوة 1، وaudهوnotes-app.iatوexpبالثواني منذ 1 يناير 1970 Unix Time، والأمرdate -u -d @1791230529يحولها إلىMon Oct 5 20:02:09 UTC 2026، والفرق بينهما ساعة واحدة.subقيمة لا معنى لها للإنسان، لأن Dex يبنيها من معرف المستخدم واسم مصدر الدخول، والتطبيق يحفظها كما هي ولا يحاول تفسيرها.at_hashوc_hashهاش جزء من الـ Access Token والكود، يربطان الـ ID Token بهما حتى لا يبدل أحد واحداً منهما بآخر.
نتحقق من التوقيع كما يفعل التطبيق
القراءة ليست تحققاً، فالآن نكتب ما تفعله أي مكتبة OIDC عند استلام الـ ID Token: تجلب ملف الاكتشاف ثم JWKS، وتختار المفتاح بالـ kid، وتتحقق من التوقيع، ثم تفحص الحقول. احفظ الملف التالي باسم verify-id-token.mjs، ولا يحتاج أي مكتبة خارجية لأن Node.js يقرأ مفاتيح JWK مباشرة:
// Verifies an ID token the way a client must: signature with the IdP's public key (JWKS), then iss, aud, exp, nonce.
import { createPublicKey, verify } from 'node:crypto';
const [token, expectedNonce] = process.argv.slice(2);
const ISSUER = 'http://127.0.0.1:18556/dex';
const CLIENT_ID = 'notes-app';
const [h, p, s] = token.split('.');
const header = JSON.parse(Buffer.from(h, 'base64url'));
const payload = JSON.parse(Buffer.from(p, 'base64url'));
// 1) the IdP publishes its public keys in jwks_uri (from the discovery document)
const { jwks_uri } = await (await fetch(`${ISSUER}/.well-known/openid-configuration`)).json();
const { keys } = await (await fetch(jwks_uri)).json();
const jwk = keys.find(k => k.kid === header.kid);
if (!jwk || header.alg !== 'RS256') throw new Error('unknown key or unexpected alg');
// 2) the signature covers "header.payload" exactly as it was sent
const ok = verify('RSA-SHA256', Buffer.from(`${h}.${p}`), createPublicKey({ key: jwk, format: 'jwk' }), Buffer.from(s, 'base64url'));
console.log('signature :', ok ? 'valid' : 'INVALID');
// 3) the claims
const now = Math.floor(Date.now() / 1000);
console.log('iss :', payload.iss === ISSUER ? 'ok' : 'WRONG');
console.log('aud :', payload.aud === CLIENT_ID ? 'ok' : 'WRONG');
console.log('exp :', payload.exp > now ? `ok (${payload.exp - now}s left)` : 'EXPIRED');
console.log('nonce :', payload.nonce === expectedNonce ? 'ok' : 'WRONG');في الكود أعلاه لاحظ التالي:
- الخوارزمية يحددها التطبيق مسبقاً (
RS256)، ولا يأخذها من ترويسة التوكن ثقة بها، والسبب أن ترويسة التوكن يكتبها من صنع التوكن، وقد ظهرت ثغرات في مكتبات JWT كانت تقبل"alg":"none"أي توكن دون توقيع، لذلك ينص RFC 8725 لأفضل ممارسات JWT على أن يحدد التطبيق الخوارزميات المسموحة بنفسه. - التوقيع يحسب على النص
header.payloadكما وصل، وليس على JSON بعد إعادة ترتيبه. - في التطبيق الحقيقي تحفظ JWKS مدة قصيرة بدلاً من جلبها مع كل طلب، وتسمح بفارق دقيقة أو اثنتين في
expلاختلاف الساعات.
ونشغله بالتوكن والـ nonce:
node verify-id-token.mjs "$ID_TOKEN" "$NONCE"signature : valid
iss : ok
aud : ok
exp : ok (3572s left)
nonce : okوالآن نكسره عمداً: نغير البريد في المحتوى إلى [email protected] ونبقي الترويسة والتوقيع كما هما، وهذا بالضبط ما قد يحاوله شخص يريد أن يدخل باسم المدير:
TAMPERED=$(node -e '
const [h, p, s] = process.argv[1].split(".");
const claims = JSON.parse(Buffer.from(p, "base64url"));
claims.email = "[email protected]";
console.log([h, Buffer.from(JSON.stringify(claims)).toString("base64url"), s].join("."));
' "$ID_TOKEN")
node verify-id-token.mjs "$TAMPERED" "$NONCE"signature : INVALID
iss : ok
aud : ok
exp : ok (3572s left)
nonce : okولاحظ أن كل الحقول ما زالت صحيحة، والتوقيع وحده هو الذي كشف التعديل، لذلك فالتطبيق الذي يقرأ محتوى JWT دون أن يتحقق من توقيعه يقبل أي شيء يكتبه المستخدم.
نستدعي UserInfo بالـ Access Token
الآن نقوم بالخطوة 7، فنستخدم الـ Access Token في طلب إلى نقطة UserInfo، ثم نرسل نفس الطلب دون توكن:
ACCESS_TOKEN=$(sed -E 's/.*"access_token":"([^"]+)".*/\1/' token.json)
curl -s -H "Authorization: Bearer $ACCESS_TOKEN" http://127.0.0.1:18556/dex/userinfo
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18556/dex/userinfo{"iss":"http://127.0.0.1:18556/dex","sub":"CiQwOGE4Njg0Yi1kYjg4LTRiNzMtOTBhOS0zY2QxNjYxZjU0NjYSBWxvY2Fs","aud":"notes-app","exp":1791234129,"iat":1791230529,"nonce":"83750df7dfd68a031597b6a8ae4445f8","at_hash":"ogLtOTPU-XI6r54a-zwwmw","email":"[email protected]","email_verified":true,"name":"Alice"}
401ولاحظ أن نقطة UserInfo ردت بنفس sub الموجود في الـ ID Token، وهذا شرط في المواصفة يجب أن يفحصه التطبيق، وأن الطلب دون توكن رفض بالرمز 401.
نجدد التوكن ونرى التدوير Rotation
نرسل الـ Refresh Token إلى نقطة التوكن لنحصل على توكنات جديدة:
REFRESH_TOKEN=$(sed -E 's/.*"refresh_token":"([^"]+)".*/\1/' token.json)
curl -s -u notes-app:lab-only-secret-change-me \
-d grant_type=refresh_token \
-d refresh_token=$REFRESH_TOKEN \
http://127.0.0.1:18556/dex/token | tee token2.json{"access_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6ImE1MTU0YmRi…","token_type":"bearer","expires_in":3599,"refresh_token":"Chl2ZTJsdnJwYWhudWhuZWw0M21zMmZzdHV2Ehl4…","id_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6ImE1MTU0YmRi…"}ولاحظ أن الـ Refresh Token في الرد يختلف عن القديم، لأن Dex يدور Rotate توكن التجديد مع كل استخدام. والآن ننتظر بضع ثوان ونستخدم الـ Refresh Token القديم مرة أخرى، كما لو أن أحداً سرقه قبل أن يستخدمه التطبيق:
curl -s -u notes-app:lab-only-secret-change-me \
-d grant_type=refresh_token \
-d refresh_token=$REFRESH_TOKEN \
http://127.0.0.1:18556/dex/token{"error":"invalid_request","error_description":"Refresh token is invalid or has already been claimed by another client."}فالتوكن القديم لم يعد صالحاً، ويسمح Dex باستخدامه مرة ثانية خلال 3 ثوان فقط بالإعداد الافتراضي reuseInterval، حتى لا يفشل طلبان متزامنان من نفس التطبيق.
نجرب تدفق الأجهزة
نقوم الآن بدور أداة سطر أوامر تريد تسجيل الدخول، فنطلب كوداً بالتطبيق العام cli-tool الذي لا يملك سراً:
curl -s -d client_id=cli-tool -d "scope=openid email" http://127.0.0.1:18556/dex/device/code | tee device.json{
"device_code": "pnf3qooo6rra3sayq64a4zgu7ex7rbd5zuksnjgllzttcfsyk3u",
"user_code": "MQSM-SHKK",
"verification_uri": "http://127.0.0.1:18556/dex/device",
"verification_uri_complete": "http://127.0.0.1:18556/dex/device?user_code=MQSM-SHKK",
"expires_in": 300,
"interval": 5
}في المخرج أعلاه لاحظ التالي: user_code قصير وسهل الكتابة وهو ما تعرضه الأداة للمستخدم، وdevice_code طويل ويبقى عند الأداة لتسأل به عن النتيجة، وinterval يقول للأداة ألا تسأل أكثر من مرة كل 5 ثوان، وexpires_in يعطي المستخدم 5 دقائق ليوافق. والآن تسأل الأداة نقطة التوكن قبل أن يوافق المستخدم:
DEVICE_CODE=$(sed -nE 's/.*"device_code": "([^"]+)".*/\1/p' device.json)
curl -s -d grant_type=urn:ietf:params:oauth:grant-type:device_code \
-d device_code=$DEVICE_CODE \
-d client_id=cli-tool \
http://127.0.0.1:18556/dex/token{"error":"authorization_pending"}افتح verification_uri في المتصفح، واكتب الكود الذي عرضته الأداة، ثم سجل الدخول بنفس الحساب:

وبعد الدخول تظهر رسالة تطلب منك العودة إلى جهازك:

ونعيد نفس طلب الأداة، فيرد هذه المرة بالتوكنات (اختصرناها):
{"access_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6ImE1MTU0YmRi…","token_type":"bearer","expires_in":3599,"id_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6ImE1MTU0YmRi…"}ولاحظ أن الأداة لم تر كلمة المرور، ولم تحتج متصفحاً، ولم يصلها Refresh Token لأنها لم تطلب offline_access.
إزالة التجربة
وبعد الانتهاء نحذف الـ Container والشبكة والـ Image، ثم الملفات التي تحمل التوكنات:
docker compose down -v
docker rmi ghcr.io/dexidp/dex:v2.45.1
rm -f token.json token2.json device.jsonالتوكنات والجلسات وتسجيل الخروج
بعد أن يحصل التطبيق على التوكنات يبقى سؤال عملي: أين يحفظها، وكم تعيش، وماذا يحدث عندما يضغط المستخدم «تسجيل الخروج»؟
أين تحفظ التوكنات؟
الحل الأسهل في تطبيقات الصفحة الواحدة SPA أن تحفظ التوكنات في localStorage داخل المتصفح، وهذه الطريقة غير مناسبة إطلاقاً، والسبب أن أي سكربت يعمل في الصفحة يستطيع قراءة localStorage، فإذا وجد المخترق ثغرة حقن سكربت XSS واحدة في أي صفحة، أو اخترق مكتبة JavaScript تحملها الصفحة، فإنه يأخذ التوكنات ويستخدمها من جهازه، والـ Refresh Token يبقى صالحاً أياماً بعد ذلك. والحل الأنسب هو نمط الخلفية للواجهة Backend for Frontend واختصاراً BFF، الذي توصي به مسودة OAuth للتطبيقات التي تعمل في المتصفح، حيث يقوم سيرفر التطبيق بدور الـ Client، فيحفظ التوكنات عنده بجانب الجلسة، ولا يعطي المتصفح إلا ملف تعريف ارتباط Cookie للجلسة، لا يستطيع JavaScript أن يقرأه:

وتطبيقاتك المستضافة ذاتياً مثل Gitea وOutline وGrafana تعمل بهذا الشكل أصلاً، فهي تطبيقات تعمل على السيرفر وتحفظ التوكنات عندها، والمهم أن يكون ملف تعريف الارتباط للجلسة بالخصائص Secure وHttpOnly وSameSite، وقد شرحنا كل خاصية منها وكيف تضيفها عبر الـ Reverse Proxy في قسم ملفات تعريف الارتباط من دليل ترويسات الأمان.
مدة صلاحية التوكنات وتدوير Refresh Token
القاعدة أن يكون عمر التوكن بحجم الضرر إذا سرق، لذلك يكون الـ Access Token قصيراً، من 5 دقائق إلى ساعة، لأنه يرسل مع كل طلب إلى الـ API ويمر في أماكن أكثر، والـ Refresh Token أطول، من ساعات إلى أسابيع، لأنه لا يذهب إلا إلى مزود الهوية. وتدوير Refresh Token Rotation الذي رأيناه في التجربة يعني أن كل استخدام يعطي توكن تجديد جديداً ويلغي القديم، فإذا سرق التوكن واستخدمه المخترق ثم حاول التطبيق الحقيقي استخدامه، أو العكس، يكتشف المزود أن التوكن استخدم مرتين، ويستطيع أن يلغي السلسلة كلها. ولهذا ينص RFC 9700 على أن توكنات التجديد للتطبيقات العامة يجب أن تكون مرتبطة بالجهاز الذي صدرت له Sender-Constrained، أو أن تدور مع كل استخدام.
تسجيل الخروج والإلغاء Revocation
عندما تتذكر أن هناك ثلاث جلسات منفصلة يصبح تسجيل الخروج واضحاً: جلسة التطبيق، وجلسة مزود الهوية، والتوكنات التي في يد التطبيق، وزر الخروج في التطبيق يحذف الجلسة الأولى فقط، فإذا ضغطت «تسجيل الدخول» بعدها مباشرة يعيدك مزود الهوية دون كلمة مرور لأن جلسته ما زالت قائمة. والأدوات المتاحة لإغلاق الباقي هي:
- إلغاء التوكن Token Revocation: يرسل التطبيق التوكن إلى نقطة الإلغاء في مزود الهوية كما في RFC 7009، فلا يعود صالحاً، وهذا مهم خاصة للـ Refresh Token.
- الخروج الذي يبدؤه التطبيق RP-Initiated Logout: يحول التطبيق المستخدم إلى نقطة الخروج في مزود الهوية بعد أن يحذف جلسته، فيحذف المزود جلسته أيضاً، ثم يعيده إلى عنوان مسجل، كما في مواصفة RP-Initiated Logout.
- الخروج عبر القناة الخلفية Back-Channel Logout: عندما يسجل المستخدم الخروج في مزود الهوية، أو يعطله المدير، يرسل المزود طلباً مباشراً إلى كل تطبيق مسجل ليحذف جلسة هذا المستخدم، كما في مواصفة Back-Channel Logout، ودعم هذه المواصفة يختلف من تطبيق لآخر، لذلك تأكد منه في توثيق كل تطبيق قبل أن تعتمد عليه.
قائمة أفضل الممارسات
هذه القائمة تجمع ما سبق في نقاط تراجعها عند ربط أي تطبيق بمزود الهوية، ومعظمها مأخوذ من RFC 9700:
- استخدم Authorization Code مع PKCE بالطريقة S256 لكل تطبيق فيه مستخدم، والسبب أنه يحمي من سرقة الكود وحقنه حتى مع التطبيقات السرية.
- سجل عناوين العودة كاملة ومحددة دون نجمة ودون مسارات عامة، والسبب أن أي مرونة في المطابقة تفتح باباً لتحويل الكود إلى المخترق.
- اطلب أقل النطاقات التي يحتاجها التطبيق فعلاً، فتطبيق يحتاج الاسم والبريد لا يحتاج
groupsأوoffline_access، والسبب أن ضرر التوكن المسروق بحجم نطاقاته. - احفظ Client Secret في السيرفر فقط، في ملف
.envأو مدير أسرار Secrets Manager، ولا تقم بوضعه في كود يعمل في المتصفح أو تطبيق جوال أو مستودع Git، والسبب أن السر المكشوف يحول تطبيقك السري إلى تطبيق عام دون أن تعلم. - تحقق من الـ ID Token كاملاً: التوقيع بمفتاح من JWKS، و
issوaudوexpوnonce، وحدد الخوارزميات المسموحة بنفسك، ولا تكتب هذا بيدك في تطبيق حقيقي وإنما استخدم مكتبة OIDC معروفة. - اربط الحساب بالحقلين
issوsubمعاً وليس بالبريد، والسبب أن البريد قد يتغير أو يعاد استخدامه، وأن مزودين مختلفين قد يعطيان نفس البريد لشخصين مختلفين. - لا تقم بوضع التوكنات في الروابط أو السجلات، فلا ترسل الـ Access Token كمعامل في الرابط
?access_token=وإنما في الترويسةAuthorization، وتأكد أن سجلات التطبيق والـ Reverse Proxy لا تكتب ترويسةAuthorizationولا جسم طلبات/token. - اجعل الأعمار قصيرة وفعل التدوير: Access Token بالدقائق، وRefresh Token يدور مع كل استخدام، والسبب أن ذلك يحدد مدة استفادة المخترق من أي توكن يسرقه.
- احفظ التوكنات في السيرفر مع Cookie للجلسة بالخصائص
SecureوHttpOnlyوSameSite، ولا تحفظها فيlocalStorage. - اضبط الساعة في كل سيرفر بخدمة NTP، واسمح بفارق صغير لا يزيد على دقيقتين في فحص
expوiat، والسبب أن السيرفر الذي تتأخر ساعته يرفض توكنات صحيحة أو يقبل توكنات منتهية. - استخدم HTTPS في كل مكان عدا التجربة المحلية على
127.0.0.1، والسبب أن OAuth 2.0 يعتمد على TLS لحماية التوكنات من نوع Bearer أثناء نقلها.
أخطاء شائعة وكيف تعرفها
- الخطأ
redirect_uri_mismatchأو «Unregistered redirect_uri»: انسخ قيمةredirect_uriمن رابط التفويض في شريط العنوان، وقارنها بالمسجلة حرفاً بحرف، فإذا كانت تبدأ بـhttp://أو فيها منفذ داخلي فالتطبيق خلف Reverse Proxy ولا يعرف عنوانه العام، وحله في قسم redirect_uri_mismatch من دليل التطبيق خلف Reverse Proxy. - الخطأ
invalid_grantعند استبدال الكود: الكود استخدم من قبل (غالباً لأن المستخدم أعاد تحميل صفحة العودة)، أو انتهت صلاحيته، أو الـcode_verifierلا يطابق لأن التطبيق فقد جلسته بين الخطوة 1 والخطوة 5، وهذا يحدث عندما يعمل التطبيق على أكثر من نسخة Instance دون جلسات مشتركة. - رفض
stateأو حلقة تحويل لا تنتهي: ملف تعريف الارتباط الذي يحفظstateلم يرجع مع طلب العودة، وأشهر أسبابهSameSite=Strictعلى ملف الجلسة، أو اختلاف النطاق بين بداية الدخول ونهايته، مثلexample.comوwww.example.com. - الخطأ «issuer does not match»: قيمة
issفي التوكن لا تطابق ما كتبته في التطبيق، وكثيراً ما يكون الفرق شرطة مائلة في الآخر، أو أن التطبيق يصل إلى المزود بعنوان داخلي والمزود يعلن عنوانه العام. - التوكن منتهي فور إصداره: ساعة أحد السيرفرات متأخرة أو متقدمة، فقارن
date -uعلى سيرفر التطبيق وسيرفر المزود. - لا يصل Refresh Token: التطبيق لم يطلب
offline_access، أو المزود لا يسمح بهذا النطاق للتطبيق، كما في authentik منذ الإصدار 2024.2. - إرسال الـ ID Token إلى الـ API: يعمل في البداية لأن الـ API يقرأ JWT صحيحاً، ولكنه يقبل توكناً لم يصدر من أجله، فاجعل الـ API يفحص
audويرفض أي توكن ليس موجهاً إليه.
OAuth وOIDC وSAML وLDAP: ما الفرق؟
سوف تقابل هذه الأسماء معاً في كل مزود هوية، وهي لا تتنافس على نفس الدور، والجدول التالي يضع كل واحد في مكانه، وسوف نخصص لكل من SAML وLDAP مقالاً مستقلاً بإذن الله:
| البروتوكول | يجيب عن سؤال | الشكل | أين تراه |
|---|---|---|---|
| OAuth 2.0 | ما الذي يسمح لهذا التطبيق أن يفعله؟ (تفويض) | توكنات عبر HTTP وJSON | APIs، وتطبيقات تتصل بحساباتك في خدمات أخرى |
| OpenID Connect | من هو المستخدم الذي دخل الآن؟ (هوية فوق OAuth) | ID Token بصيغة JWT | أزرار «الدخول بحساب…»، وربط Gitea وOutline وGrafana بمزود الهوية |
| SAML 2.0 | من هو المستخدم؟ (هوية، الجيل الأقدم) | رسائل XML موقعة عبر المتصفح | أنظمة المؤسسات الكبيرة والخدمات السحابية التجارية |
| LDAP | أين دليل المستخدمين والمجموعات، وهل كلمة المرور صحيحة؟ | بروتوكول دليل Directory على المنفذ 389 أو 636 | Active Directory، والتطبيقات القديمة التي تتحقق من كلمة المرور بنفسها |
| SCIM | كيف تنشأ الحسابات وتحذف في التطبيقات تلقائياً؟ | REST API بصيغة JSON | شرحناه في ما هو SCIM |
والقاعدة العملية لأي تطبيق جديد تربطه بمزود الهوية: إذا دعم OpenID Connect فاختره، وإذا كان يدعم SAML فقط فهو الخيار التالي، أما LDAP فاتركه للتطبيقات التي لا تدعم غيره، لأن التطبيق في LDAP يستلم كلمة مرور المستخدم بنفسه، أي أنه يعيدنا إلى المشكلة التي بدأنا بها هذا المقال.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- جاء OAuth لينهي مشاركة كلمة المرور مع التطبيقات، فيأخذ كل تطبيق توكن محدوداً بنطاقات ومدة، ويمكن سحبه وحده، ولا يرى كلمة المرور أبداً.
- OAuth 2.0 للتفويض، وOpenID Connect يضيف فوقه إثبات الهوية بالـ ID Token، فالـ ID Token للتطبيق ليعرف من دخل، والـ Access Token للـ API ليعرف ما المسموح.
- تدفق Authorization Code مع PKCE هو التدفق الوحيد الذي تحتاجه لأي تطبيق فيه مستخدم، والكود يمر في المتصفح والتوكنات لا تمر إلا بين السيرفرات.
- Client Credentials لتطبيق يعمل باسمه، وDevice Flow لجهاز دون متصفح، أما Implicit وPassword فلا يستخدمان في أي تطبيق جديد.
- JWT يقرؤه أي أحد، والتوقيع بالمفتاح الخاص لمزود الهوية هو الذي يكشف أي تعديل، فتحقق منه بالمفتاح العام من JWKS ثم افحص
issوaudوexpوnonce. - عناوين العودة تطابق حرفاً بحرف، والأسرار تبقى في السيرفر، والتوكنات أعمارها قصيرة وتدور، وتسجيل الخروج يشمل التطبيق ومزود الهوية والتوكنات معاً.
والآن عندما تعود إلى أدلتنا العملية سوف تعرف سبب كل حقل تكتبه، فابدأ بها:
دليلتثبيت authentik للدخول الموحد SSO إلى تطبيقاتككيف تثبت authentik حتى يدخل كل موظف إلى تطبيقات الفريق بحساب واحد، مع فرض المصادقة الثنائية على الجميع.
دليلكيف تحمي أي تطبيق ويب بصفحة دخول authentik عبر Forward Authكيف تضع صفحة دخول authentik مع المصادقة الثنائية أمام أي أداة داخلية دون أن تعدل في التطبيق نفسه.
دليلتسجيل الدخول بحساب Google في authentik وقصره على نطاق مؤسستككيف تضيف زر الدخول بحساب Google إلى authentik، وتقصر التسجيل على حسابات نطاق مؤسستك وحده.
دليلدخول موحد إلى Gitea بحسابات authentikاجعل موظفيك يدخلون إلى Gitea بحساباتهم في authentik، فتنشأ حساباتهم تلقائياً وتتبع صلاحياتهم المجموعة التي ينتمون إليها.
دليلHSTS وCSP: ترويسات الأمان التي يحتاجها كل تطبيقما الذي تطلبه كل ترويسة أمان من المتصفح، ولماذا يصعب التراجع عن HSTS، وكيف تبني سياسة CSP لا تكسر تطبيقك.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ تدفق Authorization Code مع PKCE وتدفق الأجهزة وتجديد التوكن على Dex v2.45.1 مع curl وNode.js.