في دليل authentik بنينا مزود هوية Identity Provider خفيفاً يناسب فريقاً صغيراً، ولكن عندما تدخل مؤسسة أكبر قليلاً، أو تعمل مع جهة حكومية أو بنك، فسوف تجد أن الاسم الذي يتكرر في كراسات الشروط وفي وثائق الأنظمة الجاهزة هو Keycloak، حيث تأتي كثير من الأنظمة المؤسسية بدليل جاهز لربطها به عبر OIDC أو SAML، ويعرفه مهندسو الأنظمة الذين سوف توظفهم، وتبني عليه شركات كبيرة منتجاتها التجارية. لذلك فالسؤال عادة ليس هل تستخدم مزود هوية، وإنما أي مزود يناسب حجمك، وKeycloak هو الإجابة عندما تحتاج إلى نطاقات Realms منفصلة، وربط مع Active Directory أو LDAP، وتدفقات دخول Authentication Flows تبنيها خطوة خطوة.
ولكن Keycloak تطبيق Java كبير، فإذا ثبته كما في المثال الأول من التوثيق فسوف يعمل، ولكنه سوف يقلع في دقائق بدل ثوان، ويأخذ من الذاكرة RAM ما يتسع له السيرفر، ويترك الحماية من التخمين Brute Force معطلة، ولا يفرض المصادقة الثنائية 2FA على أحد. وهذه الأمور كلها لا تظهر لك في أول تجربة، وإنما تظهر عندما يكون النظام في بيئة الإنتاج Production.
وفي هذا المقال سوف نتناول:
- ما هو Keycloak، ورخصته، وسرعة صدور إصداراته الأمنية، ومفاهيمه الأساسية: النطاق Realm والتطبيق Client والتدفق Flow.
- تثبيته مع PostgreSQL باستخدام Docker Compose، مع Docker Image مبنية مسبقاً Optimized تقلع في نصف دقيقة.
- إنشاء نطاق ومستخدمين ومجموعات، وربط تطبيق تجريبي عبر OIDC مع PKCE، وقراءة ما يصل إليه في الـ ID Token.
- واجهة الدخول بالعربية واتجاه RTL، وما ينقص الترجمة.
- فرض رمز TOTP على كل المستخدمين، والحماية من التخمين، والخطأ الذي يجعل صفحة الدخول ترفض الجميع.
- الذاكرة والقرص بأرقام مقيسة، والـ Reverse Proxy مع TLS، والنسخ الاحتياطي والاستعادة، والتحديث.
وإذا كانت المفاهيم الأساسية جديدة عليك فابدأ بهذه المقالات، حيث نفترض هنا أنك تعرف ما هو OIDC وما هو الـ ID Token:
دليلما هو الدخول الموحد SSO؟ المصطلحات التي تحتاجها أولاًخريطة لمصطلحات الدخول الموحد SSO مع تجربة حقيقية تبين كيف تفتح جلسة واحدة في مزود الهوية تطبيقين، وماذا يبقى بعد الخروج.
دليلشرح OAuth وOpenID Connect للمبتدئينلماذا لا يجب أن يعرف أي تطبيق كلمة مرورك، وكيف يحل OAuth ذلك بتوكنات محدودة، وكيف يضيف OpenID Connect الدخول الموحد.
دليلتثبيت authentik للدخول الموحد SSO إلى تطبيقاتككيف تثبت authentik حتى يدخل كل موظف إلى تطبيقات الفريق بحساب واحد، مع فرض المصادقة الثنائية على الجميع.
دليلالدخول دون كلمة مرور: شرح Passkeys وWebAuthnلماذا لا تحميك رموز SMS وTOTP من التصيد الحديث، وكيف تحل Passkeys المشكلة بمفتاح خاص لا يغادر جهازك.ما هو Keycloak؟
Keycloak هو مزود هوية مفتوح المصدر Open Source بدأته Red Hat، ويصدر برخصة Apache 2.0، وهي رخصة تسمح لك بتشغيله وتعديله داخل منتج تجاري دون أن تنشر كودك، وقد قبلته مؤسسة CNCF في 10 أبريل 2023 بمستوى Incubating. ويتحدث Keycloak البروتوكولات الثلاثة التي تحتاجها المؤسسات: OpenID Connect وOAuth 2.0 للتطبيقات الحديثة، وSAML 2.0 للأنظمة القديمة، ويستطيع أن يقرأ المستخدمين من LDAP أو Active Directory بدل أن ينسخهم، وهذه الخاصية اسمها اتحاد المستخدمين User Federation.
والمشروع نشط جداً، فآخر إصدار عند كتابة هذا الدليل هو 26.8.1 في 13 أكتوبر 2026، وقبله 26.8.0 في 1 أكتوبر، و26.7.5 في 30 سبتمبر. وهذه السرعة لها جانب يجب أن تنتبه له، حيث نشر المشروع في صفحة التنبيهات الأمنية 17 تنبيهاً منذ بداية 2026، منها 12 بدرجة High، وضم الإصدار 26.8.1 وحده إصلاحات لنحو عشرين ثغرة، منها ثغرة CVE-2026-94218 التي تسمح بتجاوز إلزام المستخدم بإعداد المصادقة الثنائية، وهي بالضبط الخاصية التي سوف نعتمد عليها في هذا الدليل. لذلك فتحديث Keycloak ليس مهمة ربع سنوية، وإنما مهمة تتابعها كل أسبوعين أو ثلاثة.
المفاهيم التي سوف تتعامل معها كل يوم
قبل التثبيت يجب أن تعرف أربعة مصطلحات، والسبب أن واجهة Keycloak مبنية عليها، وكل خطأ في الإعداد تقريباً يعود إلى خلط بينها:
- النطاق Realm: مساحة مستقلة لها مستخدموها ومجموعاتها وتطبيقاتها وسياساتها، ولا يرى نطاق ما في نطاق آخر. ويأتي Keycloak بنطاق اسمه
master، وهذا النطاق للمديرين فقط، ولا تضع فيه موظفيك ولا تطبيقاتك، وإنما أنشئ نطاقاً لمؤسستك. - التطبيق Client: كل تطبيق يسجل الدخول عبر Keycloak، وله معرف Client ID وسر Client Secret إن كان تطبيقاً سرياً Confidential، وقائمة بعناوين العودة Redirect URIs المسموحة.
- المستخدمون Users والمجموعات Groups والأدوار Roles: الحسابات وطريقة تجميعها، ومنها تبني صلاحيات كل تطبيق.
- التدفق Authentication Flow: سلسلة الخطوات التي يمر بها المستخدم عند الدخول، مثل كلمة المرور ثم رمز TOTP، وكل خطوة لها متطلب Requirement: إلزامية Required، أو بديلة Alternative، أو مشروطة Conditional، أو معطلة Disabled.
والشكل التالي يبين هذه المفاهيم داخل سيرفر واحد، ومعها خطوات دخول المستخدم إلى تطبيق تجريبي سوف نبنيه:

المتطلبات
- سيرفر Linux بمعالجين 2 vCPU على الأقل، وذاكرة 2 GB تكفي لـ Keycloak وPostgreSQL معاً إذا حددت حداً للذاكرة كما سيأتي، و4 GB أفضل إذا كانت على السيرفر تطبيقات أخرى.
- مساحة قرص بنحو 3 GB، والسبب أن الـ Image وحدها 751 MB، والنسخة المبنية مسبقاً مثلها، أما قاعدة البيانات فصغيرة جداً، حيث كانت 13 MB بعد إعداد النطاق والمستخدمين في هذا الدليل.
- Docker Engine مع Compose v2، وإذا لم يكن مثبتاً فاتبع دليل تثبيت Docker أولاً.
- نطاق فرعي Subdomain مثل
auth.example.comيشير إلى السيرفر، وReverse Proxy يصدر شهادة TLS، مثل Caddy أو Nginx Proxy Manager. - حساب SMTP لرسائل استعادة كلمة المرور والتحقق من البريد، وتستطيع تأجيله أثناء التجربة.
التثبيت
لماذا نبني Docker Image خاصة؟
Keycloak مبني على إطار Quarkus، وهذا الإطار يجهز التطبيق في خطوة اسمها البناء Build بحسب الإعدادات التي لا تتغير، مثل نوع قاعدة البيانات وتفعيل نقاط الفحص Health Endpoints. فإذا شغلت الـ Image الرسمية مباشرة بالأمر start فسوف يكتشف Keycloak أن هذه الإعدادات تغيرت وينفذ خطوة البناء في كل مرة تنشئ فيها Container جديداً، وعند تشغيله أول مرة استغرقت هذه الخطوة وحدها 134 ثانية، ثم احتاج Keycloak إلى 140 ثانية أخرى لإنشاء جداول قاعدة البيانات، فكان المجموع 5 دقائق و17 ثانية حتى أجاب أول طلب. لذلك يوصي التوثيق ببناء Image خاصة تنفذ فيها خطوة البناء مرة واحدة، ثم تشغلها بالخيار --optimized، وبهذه الطريقة صار الـ Container جاهزاً Healthy في 34 ثانية.
هيكل المجلد والأسرار Secrets
sudo mkdir -p /opt/keycloak
cd /opt/keycloak
echo "PG_PASS=$(openssl rand -hex 24)" >> .env
echo "KC_ADMIN_PASS=$(openssl rand -hex 16)" >> .env
chmod 600 .envويحمل الملف .env سرين: كلمة مرور PostgreSQL، وكلمة مرور المدير المؤقت الذي ينشئه Keycloak في أول تشغيل، وسوف نعود إليه بعد قليل.
ملف Dockerfile للنسخة المبنية مسبقاً
FROM quay.io/keycloak/keycloak:26.8.1 AS builder
ENV KC_DB=postgres
ENV KC_HEALTH_ENABLED=true
ENV KC_METRICS_ENABLED=true
RUN /opt/keycloak/bin/kc.sh build
FROM quay.io/keycloak/keycloak:26.8.1
COPY --from=builder /opt/keycloak/ /opt/keycloak/
ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]docker build -t keycloak-custom:26.8.1 .واستغرق البناء 83 ثانية، ولاحظ أننا ثبتنا رقم الإصدار 26.8.1 في السطرين، والسبب أن الوسم latest يجعل كل بناء جديد ينزل إصداراً مختلفاً دون أن تقرر أنت ذلك، وترقية Keycloak تعدل جداول قاعدة البيانات ولا رجوع عنها إلا من نسخة احتياطية Backup.
ملف docker-compose.yml
name: keycloak
services:
postgres:
image: postgres:17.6-alpine
restart: unless-stopped
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: ${PG_PASS:?}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"]
interval: 10s
timeout: 5s
retries: 10
volumes:
- db:/var/lib/postgresql/data
keycloak:
image: keycloak-custom:26.8.1
restart: unless-stopped
command: start --optimized
mem_limit: 768m
healthcheck:
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3 && grep -q 'HTTP/1.1 200' <&3"]
interval: 15s
timeout: 5s
retries: 20
start_period: 60s
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: ${PG_PASS}
KC_HOSTNAME: http://localhost:18815
KC_HTTP_ENABLED: "true"
KC_HEALTH_ENABLED: "true"
KC_BOOTSTRAP_ADMIN_USERNAME: tmpadmin
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASS:?}
ports:
- "127.0.0.1:18815:8080"
depends_on:
postgres:
condition: service_healthy
volumes:
db:في الإعداد أعلاه لاحظ التالي:
KC_HOSTNAMEهو العنوان العام الذي يبني منه Keycloak كل روابطه، ومنها حقلissفي التوكنات، وفي المختبر هوhttp://localhost:18815، وفي بيئة الإنتاج يكونhttps://auth.example.comكما في قسم الـ Reverse Proxy. وإذا غيرته بعد ربط التطبيقات فسوف ترفض التطبيقات كل التوكنات، لأن الـ Issuer لم يعد يطابق ما حفظته.KC_HTTP_ENABLEDيسمح بـ HTTP داخل الشبكة، والسبب أن الـ Reverse Proxy هو الذي ينهي TLS، ولكن لا تنشر المنفذ 8080 للإنترنت أبداً، لذلك ربطناه بالعنوان127.0.0.1.- الـ Image الرسمية لـ Keycloak لا تضم
curlولاwget، لذلك يفتح فحص الصحة Health Check اتصالاً عبر/dev/tcpفي bash إلى منفذ الإدارة 9000، وهو منفذ نقاط الفحص/health/ready، ولا يحتاج إلى نشر. mem_limit: 768mحد للذاكرة، والتفصيل في قسم الموارد، وهو مهم جداً مع Java.
التشغيل ومتابعة السجلات Logs
docker compose up -d
docker compose logs -f keycloakوفي التشغيل الأول سوف ترى في السجل سطرين مهمين، الأول يقول إن Keycloak بدأ، والثاني يقول إن التهيئة Bootstrap انتهت، والمخرج سوف يكون كما يلي:
INFO [io.quarkus] (main) Keycloak 26.8.1 on JVM (powered by Quarkus 3.40.1) started in 16.770s. Listening on: http://0.0.0.0:8080. Management interface listening on http://0.0.0.0:9000.
INFO [org.keycloak.services.resources.KeycloakApplication] (executor-thread-1) Bootstrap completed in 9.272000 secondsولاحظ أن Keycloak في الإصدارات الحديثة يفتح المنفذ قبل أن تنتهي التهيئة، ويرد على كل طلب خلالها بالرمز 503 ورسالة Bootstrap in progress. Retry in 1 seconds.، وفي أول تشغيل على قاعدة بيانات فارغة تطول هذه المرحلة لأنه ينشئ الجداول، لذلك اعتمد على حالة healthy وليس على ظهور سطر Listening on:
docker compose psالمدير المؤقت ونطاق master
المتغيران KC_BOOTSTRAP_ADMIN_USERNAME وKC_BOOTSTRAP_ADMIN_PASSWORD ينشئان مديراً مؤقتاً Temporary Admin في نطاق master عند أول تشغيل فقط، ويظهر في السجل السطر Created temporary admin user with username tmpadmin، وعندما تدخل به إلى لوحة الإدارة على http://localhost:18815/admin/ سوف ترى شريطاً أصفر يقول: «You are logged in as a temporary admin user. To harden security, create a permanent admin account and delete the temporary one». والمطلوب كما يقول الشريط: أنشئ في نطاق master حساب مدير باسمك، وأعطه الدور admin، وفعل له المصادقة الثنائية، ثم ادخل به واحذف tmpadmin، واحذف المتغيرين من ملف Compose. وإذا فقدت كل حسابات المديرين لاحقاً فهناك أمر لإنشاء مدير مؤقت جديد من سطر الأوامر.
إنشاء نطاق للمؤسسة
تستطيع أن تنفذ كل الخطوات التالية من لوحة الإدارة، ولكننا سوف نكتبها بأداة kcadm.sh الموجودة داخل الـ Container، والسبب أن الأوامر تحفظ في ملف وتعيد بناء نفس الإعداد على سيرفر الاختبار أو بعد كارثة، أما النقر في الواجهة فلا يتذكره أحد. نبدأ بتسجيل الدخول بالمدير:
source .env
K="docker compose exec -T keycloak /opt/keycloak/bin/kcadm.sh"
$K config credentials --server http://localhost:8080 --realm master --user tmpadmin --password "$KC_ADMIN_PASS"ثم ننشئ النطاق arabroot ونفعل فيه تعدد اللغات Internationalization مع العربية لغة افتراضية:
$K create realms -s realm=arabroot -s enabled=true -s displayName="عرب رووت" \
-s internationalizationEnabled=true -s 'supportedLocales=["ar","en"]' -s defaultLocale=arCreated new realm with id 'arabroot'ثم مجموعة engineering ومستخدماً فيها:
$K create groups -r arabroot -s name=engineering
$K create users -r arabroot -s username=alice -s [email protected] -s emailVerified=true \
-s firstName=Alice -s lastName=Admin -s enabled=true
$K set-password -r arabroot --username alice --new-password "$ALICE_PASS"
UID_=$($K get users -r arabroot -q username=alice --fields id --format csv --noquotes)
GID_=$($K get groups -r arabroot --fields id,name --format csv --noquotes | grep engineering | cut -d, -f1)
$K update users/$UID_/groups/$GID_ -r arabroot -s realm=arabroot -s userId=$UID_ -s groupId=$GID_ -nCreated new group with id '869c14a3-624f-4578-ad9d-0d447deb7b3f'
Created new user with id '8dd77b51-47ae-44be-b4f0-64c447b186a4'والمتغير ALICE_PASS كلمة مرور من 12 حرفاً أو أكثر تولدها وتعطيها للمستخدم عبر قناة آمنة، ولاحظ الخيار -T في docker compose exec، والسبب أنه يمنع إضافة رمز نهاية السطر الخاص بالطرفية إلى المعرفات IDs التي نحفظها في المتغيرات. ولاحظ أيضاً أن كل استدعاء لـ kcadm.sh يشغل Java من جديد، لذلك يستغرق الأمر الواحد عدة ثوان، وهذا طبيعي.
سياسة كلمات المرور والحماية من التخمين
النطاق الجديد يأتي بلا سياسة لكلمات المرور، وفيه الحماية من التخمين Brute Force Detection معطلة، ولا يحفظ أحداث الدخول Events في قاعدة البيانات، وقد تحققنا من ذلك على النطاق الذي أنشأناه للتو:
$K get realms/arabroot --fields bruteForceProtected,failureFactor,eventsEnabled,passwordPolicy{
"bruteForceProtected" : false,
"failureFactor" : 30,
"eventsEnabled" : false
}وهذا يعني أن أي شخص يستطيع تجربة كلمات مرور بلا حد على أي حساب، ولا يبقى أثر لذلك إلا في سجل الـ Container. لذلك فعل الثلاثة معاً، حيث نجعل الحساب يتوقف مؤقتاً بعد خمس محاولات خاطئة، ونطلب كلمة مرور من 12 حرفاً على الأقل لا تساوي اسم المستخدم ولا تكرر آخر ثلاث كلمات:
$K update realms/arabroot -s bruteForceProtected=true -s failureFactor=5 -s eventsEnabled=true \
-s 'passwordPolicy=length(12) and notUsername and passwordHistory(3)'وسوف نرى أثر هذا الإعداد في التجربة بعد قليل.
ربط تطبيق عبر OIDC
الآن سوف نقوم بتسجيل التطبيق التجريبي demo-app تطبيقاً سرياً Confidential Client، أي تطبيقاً يعمل على السيرفر ويستطيع حفظ سر، ونفرض عليه PKCE بالطريقة S256، ونسمح بعنوان عودة واحد فقط:
CID=$($K create clients -r arabroot -s clientId=demo-app -s enabled=true -s publicClient=false \
-s standardFlowEnabled=true -s directAccessGrantsEnabled=false \
-s 'redirectUris=["http://localhost:18816/callback"]' -s 'webOrigins=["http://localhost:18816"]' \
-s 'attributes={"pkce.code.challenge.method":"S256","post.logout.redirect.uris":"http://localhost:18816/"}' -i)في الإعداد أعلاه لاحظ التالي:
directAccessGrantsEnabled=falseيعطل تدفق كلمة المرور المباشر Password Grant، وهو تدفق انتهى زمنه لأن التطبيق يرى فيه كلمة مرور المستخدم.- عنوان العودة كامل وبلا نجمة
*، والسبب أن النجمة تسمح بإرسال الكود Authorization Code إلى أي مسار في التطبيق، ومنها مسار قد يتحكم فيه مخترق.
ولا يرسل Keycloak مجموعات المستخدم في التوكن إلا إذا طلبت ذلك، لذلك نضيف ما يسمى Protocol Mapper من نوع Group Membership، ثم نقرأ سر التطبيق لنعطيه للتطبيق:
$K create clients/$CID/protocol-mappers/models -r arabroot -s name=groups -s protocol=openid-connect \
-s protocolMapper=oidc-group-membership-mapper \
-s 'config={"claim.name":"groups","full.path":"false","id.token.claim":"true","access.token.claim":"true","userinfo.token.claim":"true"}'
$K get clients/$CID/client-secret -r arabroot --fields value --format csv --noquotes
التطبيق التجريبي
حتى نرى ما يحدث فعلاً بين التطبيق وKeycloak، كتبنا تطبيقاً صغيراً بـ Node.js لا يستخدم أي مكتبة، ينفذ تدفق Authorization Code مع PKCE، ويتحقق من nonce وiss وaud في الـ ID Token، ثم يعرض الحقول Claims التي وصلته. وفي تطبيقك الحقيقي استخدم مكتبة OIDC معروفة للغتك بدل هذا الكود، والسبب أن المكتبة تتحقق من توقيع التوكن عبر JWKS، وهذا ما لا يفعله المثال لأنه يأخذ التوكن مباشرة من Keycloak عبر القناة الخلفية Back Channel. والملف demo/server.js كما يلي:
// Demo app for the Keycloak lab: OIDC Authorization Code + PKCE with Node's standard library only.
// ISSUER is the public address the browser sees; INTERNAL is how the container reaches Keycloak.
const http = require('node:http');
const crypto = require('node:crypto');
const ISSUER = process.env.ISSUER; // e.g. http://localhost:18815/realms/arabroot
const INTERNAL = process.env.INTERNAL || ISSUER;
const CLIENT_ID = process.env.CLIENT_ID;
const CLIENT_SECRET = process.env.CLIENT_SECRET;
const APP_URL = process.env.APP_URL; // e.g. http://localhost:18816
const pending = new Map(); // state -> { verifier, nonce }
const sessions = new Map(); // sid -> { claims, idToken }
const b64url = (b) => Buffer.from(b).toString('base64url');
const page = (body) => `<!doctype html><html lang="ar" dir="rtl"><meta charset="utf-8"><title>تطبيق تجريبي</title>
<style>body{font:16px system-ui;margin:40px;max-width:760px}pre{direction:ltr;text-align:left;background:#f4f4f6;padding:16px;border-radius:8px}
a.btn{display:inline-block;background:#E95420;color:#fff;padding:10px 18px;border-radius:8px;text-decoration:none}</style>${body}</html>`;
const cookie = (req, name) => (req.headers.cookie || '').split(/; */).map(c => c.split('=')).find(([k]) => k === name)?.[1];
http.createServer(async (req, res) => {
const url = new URL(req.url, APP_URL);
if (url.pathname === '/login') {
const state = b64url(crypto.randomBytes(16)), nonce = b64url(crypto.randomBytes(16));
const verifier = b64url(crypto.randomBytes(32));
const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
pending.set(state, { verifier, nonce });
const q = new URLSearchParams({ response_type: 'code', client_id: CLIENT_ID, redirect_uri: `${APP_URL}/callback`,
scope: 'openid profile email', state, nonce, code_challenge: challenge, code_challenge_method: 'S256' });
res.writeHead(302, { Location: `${ISSUER}/protocol/openid-connect/auth?${q}` }); return res.end();
}
if (url.pathname === '/callback') {
const p = pending.get(url.searchParams.get('state'));
if (!p) { res.writeHead(400); return res.end('unknown state'); }
pending.delete(url.searchParams.get('state'));
const r = await fetch(`${INTERNAL}/protocol/openid-connect/token`, { method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: 'Basic ' + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64') },
body: new URLSearchParams({ grant_type: 'authorization_code', code: url.searchParams.get('code'), redirect_uri: `${APP_URL}/callback`, code_verifier: p.verifier }) });
const t = await r.json();
if (!t.id_token) { res.writeHead(502); return res.end(JSON.stringify(t)); }
const claims = JSON.parse(Buffer.from(t.id_token.split('.')[1], 'base64url'));
if (claims.nonce !== p.nonce || claims.iss !== ISSUER || claims.aud !== CLIENT_ID) { res.writeHead(401); return res.end('bad id token'); }
const sid = b64url(crypto.randomBytes(16));
sessions.set(sid, { claims, idToken: t.id_token });
console.log(`login ${claims.preferred_username} acr=${claims.acr} amr=${JSON.stringify(claims.amr || null)}`);
res.writeHead(302, { 'Set-Cookie': `sid=${sid}; HttpOnly; SameSite=Lax; Path=/`, Location: '/' }); return res.end();
}
const s = sessions.get(cookie(req, 'sid'));
if (url.pathname === '/logout' && s) {
sessions.delete(cookie(req, 'sid'));
const q = new URLSearchParams({ id_token_hint: s.idToken, post_logout_redirect_uri: `${APP_URL}/` });
res.writeHead(302, { Location: `${ISSUER}/protocol/openid-connect/logout?${q}` }); return res.end();
}
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
if (!s) return res.end(page('<h1>تطبيق تجريبي</h1><p>لم تسجل الدخول بعد.</p><a class="btn" id="login" href="/login">الدخول عبر Keycloak</a>'));
const { claims } = s;
res.end(page(`<h1>أهلاً ${claims.name || claims.preferred_username}</h1><p>هذه الحقول Claims وصلت في الـ ID Token:</p>
<pre id="claims">${JSON.stringify({ iss: claims.iss, sub: claims.sub, preferred_username: claims.preferred_username, email: claims.email,
groups: claims.groups, acr: claims.acr, amr: claims.amr, auth_time: claims.auth_time }, null, 2)}</pre><a class="btn" href="/logout">تسجيل الخروج</a>`));
}).listen(3000, () => console.log('demo app on :3000'));ونضيف التطبيق إلى ملف Compose نفسه حتى يصل إلى Keycloak باسم الخدمة keycloak داخل الشبكة، أما المتصفح فيصل إليه عبر localhost:18815، ولهذا يحمل التطبيق عنوانين:
demo:
image: node:22.23.4-alpine
restart: unless-stopped
working_dir: /app
command: node server.js
environment:
ISSUER: http://localhost:18815/realms/arabroot
INTERNAL: http://keycloak:8080/realms/arabroot
CLIENT_ID: demo-app
CLIENT_SECRET: ${DEMO_SECRET:-unset}
APP_URL: http://localhost:18816
volumes:
- ./demo:/app:ro
ports:
- "127.0.0.1:18816:3000"ضع السر الذي قرأته في .env باسم DEMO_SECRET ثم شغل الخدمة بالأمر docker compose up -d demo. ولاحظ أن الـ Issuer الذي يتحقق منه التطبيق هو العنوان العام وليس الداخلي، والسبب أن Keycloak يكتب في حقل iss قيمة KC_HOSTNAME مهما كان العنوان الذي وصل منه الطلب.
صفحة الدخول بالعربية واتجاه RTL
عندما فتحنا التطبيق وضغطنا زر الدخول حولنا إلى صفحة Keycloak بالعربية مباشرة، لأن العربية هي اللغة الافتراضية للنطاق، وفي الصفحة قائمة للتبديل بين العربية والإنجليزية. والقالب الافتراضي keycloak.v2 يضع dir="rtl" على الصفحة كلها عندما تكون تعدد اللغات مفعلاً، لذلك جاءت الحقول والأزرار في اتجاهها الصحيح دون أي تعديل:

ولكن الترجمة ليست كاملة، فملف رسائل صفحة الدخول العربي في الإصدار 26.8.1 ينقصه 91 مفتاحاً من أصل 525 في الملف الإنجليزي، أي أن هذه الرسائل سوف تظهر للمستخدم بالإنجليزية، ومنها رسائل التحقق من البريد وإعادة الإرسال، وفي بعض الجمل الموجودة أخطاء لغوية بسيطة مثل «رمز التحقق ذات الاستخدام الواحد». والحل لا يحتاج إلى قالب Theme جديد، وإنما تعدل النص من Realm settings ← Localization ← Realm overrides، حيث تختار اللغة والمفتاح وتكتب النص الذي تريده، ويحفظ في قاعدة البيانات فلا يضيع عند التحديث. أما إذا أردت شعار مؤسستك وألوانها فهنا تحتاج إلى قالب خاص، وطريقة بنائه في دليل المطورين.
فرض المصادقة الثنائية على الجميع
يأتي تدفق الدخول الافتراضي browser بخطوة مشروطة اسمها Browser - Conditional 2FA، وهذه الخطوة تطلب رمز TOTP فقط من المستخدم الذي أعد الرمز لنفسه، أما من لم يعده فيدخل بكلمة المرور وحدها. لذلك قد تظن أنك فعلت المصادقة الثنائية لأنها تعمل في حسابك، وفي الواقع فإن كل موظف لم يفتح صفحة حسابه ويعدها يدخل بعامل واحد.
والحل البسيط الذي يخطر على البال هو أن نجعل الخطوة المشروطة إلزامية Required، وهذا ما جربناه أولاً، فتوقفت صفحة الدخول للجميع، ولم يظهر حقل اسم المستخدم أصلاً، وإنما ظهرت مباشرة رسالة «اسم المستخدم أو كلمة مرور غير صالحة.»، وفي السجل:
WARN [org.keycloak.services] (executor-thread-1) KC-SERVICES0013: Failed authentication: org.keycloak.authentication.AuthenticationFlowException
WARN [org.keycloak.events] (executor-thread-1) type="LOGIN_ERROR", realmName="arabroot", clientId="demo-app", userId="null", error="invalid_user_credentials"والسبب كان في ترتيب الخطوات وليس في المتطلب نفسه، حيث عدلنا المتطلب عبر واجهة REST الإدارية Admin REST API دون أن نرسل حقل priority، فأعاده Keycloak إلى صفر، وبالتالي صارت خطوة الرمز قبل خطوة كلمة المرور، وطلب Keycloak رمزاً من مستخدم لا يعرفه بعد. لذلك إذا عدلت التدفقات بالأوامر فأرسل الأولوية مع كل تعديل، أو راجع الترتيب في الواجهة بعده.
والطريقة التي يشرحها التوثيق هي أن تنسخ التدفق، وتضيف بعد الخطوة المشروطة خطوة ثانية تقول: إذا لم تنفذ خطوة 2FA، أي أن المستخدم لم يعد أي عامل ثان، فاطلب منه إعداد TOTP الآن. وننفذ ذلك كما يلي، مع الأولوية في كل سطر:
$K create authentication/flows/browser/copy -r arabroot -s newName=browser-mfa
$K create "authentication/flows/browser-mfa%20forms/executions/flow" -r arabroot \
-b '{"alias":"otp-default","type":"basic-flow","provider":"basic-flow","description":"Force OTP setup when no 2FA was used"}'
$K create "authentication/flows/otp-default/executions/execution" -r arabroot -b '{"provider":"conditional-sub-flow-executed"}'
$K create "authentication/flows/otp-default/executions/execution" -r arabroot -b '{"provider":"auth-otp-form"}'
$K get authentication/flows/browser-mfa/executions -r arabroot --fields id,displayName --format csv --noquotesثم نضبط المتطلب والأولوية لكل خطوة بالمعرفات IDs التي ظهرت في المخرج، ونعرف الشرط بأن يكون صحيحاً عندما لا تنفذ خطوة 2FA، ثم نجعل التدفق الجديد هو تدفق المتصفح للنطاق:
up(){ $K update authentication/flows/browser-mfa/executions -r arabroot -b "{\"id\":\"$1\",\"requirement\":\"$2\",\"priority\":$3}"; }
up <Username Password Form> REQUIRED 10
up <browser-mfa Browser - Conditional 2FA> CONDITIONAL 20
up <otp-default> CONDITIONAL 30
up <Condition - sub-flow executed> REQUIRED 10
up <OTP Form في otp-default> REQUIRED 20
$K create authentication/executions/<Condition - sub-flow executed>/config -r arabroot \
-b '{"alias":"no-2fa-yet","config":{"flow_to_check":"browser-mfa Browser - Conditional 2FA","check_result":"not-executed"}}'
$K update realms/arabroot -s browserFlow=browser-mfa
والآن عندما تدخل alice بكلمة مرورها، ولم تعد أي عامل ثان بعد، يحولها Keycloak إلى صفحة إعداد تطبيق المصادقة مع رمز QR، وفي أعلاها تنبيه يقول إن الإعداد مطلوب لتفعيل الحساب، ولا تستطيع تجاوزها:

وبعد إدخال الرمز من التطبيق عاد المتصفح إلى التطبيق التجريبي، وهذه الحقول التي وصلت في الـ ID Token:

{
"iss": "http://localhost:18815/realms/arabroot",
"sub": "8dd77b51-47ae-44be-b4f0-64c447b186a4",
"preferred_username": "alice",
"email": "[email protected]",
"groups": [
"engineering"
],
"acr": "1",
"auth_time": 1792956303
}في المخرج أعلاه لاحظ التالي:
- وصلت المجموعة
engineeringفي الحقلgroupsبفضل الـ Mapper، ومن هذا الحقل يقرر التطبيق صلاحياته، وأما المستخدم الذي ليس في أي مجموعة فلا يظهر له الحقل أصلاً، وهذا ما رأيناه مع مستخدم ثان، لذلك اكتب كود التطبيق بحيث يعامل غياب الحقل على أنه قائمة فارغة. - الحقل
acrقيمته1رغم أن الدخول كان بعاملين، ولم يصل الحقلamrالذي يذكر طرق المصادقة، والسبب أن Keycloak لا يربط مستوى المصادقة بالعاملين إلا إذا عرفت ذلك بنفسك عبر ما يسمى ACR to LoA Mapping، لذلك لا تبن على هذا الحقل في تطبيقك قبل إعداده. subمعرف ثابت لا يتغير حتى لو غير المستخدم اسمه أو بريده، فاربط به حساب المستخدم في تطبيقك وليس بالبريد.
وعند تسجيل الخروج ثم الدخول مرة ثانية طلب Keycloak الرمز بعد كلمة المرور، فأدخلنا رمزاً خاطئاً عمداً لنرى الرسالة:

otpPolicyCodeReusable قيمته false، فإذا سجل المستخدم الخروج ثم الدخول خلال نفس الثلاثين ثانية فعليه أن ينتظر الرمز التالي، وهذا سلوك صحيح يحميه من إعادة استخدام رمز سرقه شخص يراقب شاشته.ماذا يحدث عند تخمين كلمة المرور؟
بعد تفعيل الحماية من التخمين بخمس محاولات، أدخلنا لمستخدم ثان خمس كلمات مرور خاطئة ثم كلمة مروره الصحيحة، فكانت النتيجة نفس الرسالة في المرات الست: «اسم المستخدم أو كلمة مرور غير صالحة.»، أي أن الكلمة الصحيحة رفضت أيضاً، ولم يخبر Keycloak المهاجم أن الحساب توقف، وهذا مقصود حتى لا يعرف أنه اقترب. وأما المدير فيرى الحالة كما يلي:
$K get attack-detection/brute-force/users/<user-id> -r arabroot
$K get events -r arabroot --fields type,error --format csv --noquotes{
"failedLoginNotBefore" : 1792957536,
"numFailures" : 5,
"numTemporaryLockouts" : 1,
"disabled" : true,
"lastIPFailure" : "192.168.128.1",
"lastFailure" : 1792957476678
}
LOGIN_ERROR,user_temporarily_disabled
USER_DISABLED_BY_TEMPORARY_LOCKOUT,
LOGIN_ERROR,invalid_user_credentials
LOGIN_ERROR,invalid_user_credentials
LOGIN_ERROR,invalid_user_credentials
LOGIN_ERROR,invalid_user_credentials
LOGIN_ERROR,invalid_user_credentialsوالفرق بين failedLoginNotBefore ووقت آخر محاولة هو 60 ثانية، وهي مدة التوقف الأولى، وتزيد مع تكرار المحاولات حتى 15 دقيقة. ولاحظ أن الحماية مبنية على الحساب وليس على عنوان IP، لذلك يستطيع مهاجم أن يوقف حساب مدير بإدخال كلمات خاطئة عمداً، ويشرح التوثيق هذا تحت عنوان عيوب الحماية من التخمين، والحل أن تضيف أمامه حداً لعدد الطلبات Rate Limiting في الـ Reverse Proxy.
الموارد: الذاكرة RAM والقرص Disk
قد يتساءل البعض: لماذا يقول بعض الناس إن Keycloak يحتاج إلى 2 GB من الذاكرة، ويقول آخرون إنه يعمل في نصف هذا؟ والإجابة أن Java تحجز ذاكرة بحسب ما تراه متاحاً، فالـ Image تشغل Java بالخيار -XX:MaxRAMPercentage=70، أي أن حجم الـ Heap يصل إلى 70٪ من ذاكرة الـ Container، فإذا لم تضع حداً فهو 70٪ من ذاكرة السيرفر كله. وهذه الأرقام التي قسناها:
| الحالة | ذاكرة Keycloak | ملاحظة |
|---|---|---|
| دون حد للذاكرة، بعد التشغيل الأول | 1.21 GiB | على سيرفر فيه 31 GB، وانخفضت إلى 880 MiB بعد دقائق |
| دون حد، بعد إعادة التشغيل | 705 MiB | تزيد مع الاستخدام لأن الـ Heap مسموح له بالكثير |
مع mem_limit: 768m بعد التشغيل | 508 MiB | الحد الأقصى للـ Heap صار 538 MiB |
| مع الحد نفسه بعد 500 طلب توكن | 555 إلى 580 MiB | 20 طلباً متزامناً، 6.3 ثانية، بلا أي خطأ |
| PostgreSQL | 67 إلى 80 MiB | قاعدة البيانات 13 MB والـ Volume كله 71 MB |
وفي اختبار التحميل أعلاه أرسلنا 500 طلب توكن من نوع Client Credentials، كل 20 طلباً معاً، فكان زمن الاستجابة الوسيط p50 هو 131 ms، و95٪ من الطلبات انتهت في أقل من 349 ms، وهذا يكفي مؤسسة بمئات الموظفين يدخلون كل صباح. لذلك ضع حداً للذاكرة دائماً، والسبب أن Keycloak دون حد يعامل السيرفر كله كأنه له، فإذا كانت معه تطبيقات أخرى فقد يدفع نظام التشغيل إلى قتل أحدها عند نفاد الذاكرة OOM Killer.
العنوان العام مع TLS عبر Reverse Proxy
في بيئة الإنتاج يصل الموظفون إلى Keycloak عبر https://auth.example.com، والـ Reverse Proxy ينهي TLS ويمرر الطلب إلى المنفذ 8080، وهنا يحتاج Keycloak إلى أمرين: أن تخبره بالعنوان العام في KC_HOSTNAME، وأن تسمح له بقراءة الترويسات X-Forwarded-* التي يضيفها الـ Proxy، حتى يعرف عنوان IP الحقيقي للمستخدم، وهو العنوان الذي تقوم عليه سجلات الأحداث والحماية من التخمين. فنعدل متغيرات Keycloak كما يلي:
KC_HOSTNAME: https://auth.example.com
KC_PROXY_HEADERS: xforwardedومع Caddy يكفي هذا الملف، حيث يصدر Caddy الشهادة تلقائياً ويضيف الترويسات بنفسه:
auth.example.com {
reverse_proxy keycloak:8080
}وجربنا ذلك محلياً مع tls internal بدل الشهادة العامة، فصار الـ Issuer في ملف الاكتشاف Discovery يبدأ بـ https:
curl -sk https://localhost:18817/realms/arabroot/.well-known/openid-configurationhttps://localhost:18817/realms/arabroot https://localhost:18817/realms/arabroot/protocol/openid-connect/auth https://localhost:18817/realms/arabroot/protocol/openid-connect/tokenKC_PROXY_HEADERS إذا كان Keycloak يستقبل طلبات مباشرة دون Proxy، والسبب أن أي شخص يستطيع حينها إرسال ترويسة X-Forwarded-For مزورة فيظهر في السجلات بعنوان غير عنوانه ويتجاوز الحماية المبنية على العنوان، وهذا ما يحذر منه التوثيق صراحة. ولاحظ أيضاً أن المسار /admin/ يبقى متاحاً عبر النطاق العام، فإذا أردت لوحة الإدارة من الشبكة الداخلية فقط فاحجبه في الـ Proxy أو استخدم KC_HOSTNAME_ADMIN بعنوان داخلي.التحقق من النجاح
docker compose psيعرضkeycloakوpostgresبحالةhealthy.- عنوان الاكتشاف
https://auth.example.com/realms/arabroot/.well-known/openid-configurationيعيد JSON فيهissuerبالعنوان العام وبـhttps. - مستخدم جديد بلا عامل ثان يجبر على إعداد TOTP قبل أن يصل إلى التطبيق، ومستخدم أعده يطلب منه الرمز في كل دخول.
- بعد خمس كلمات مرور خاطئة ترى في Users ← المستخدم أنه متوقف مؤقتاً، وتظهر الأحداث في Events.
- الشريط الأصفر للمدير المؤقت اختفى، لأنك أنشأت مديراً دائماً وحذفت
tmpadmin.
النسخ الاحتياطي Backup والاستعادة Restore
كل حالة Keycloak في PostgreSQL: النطاقات والمستخدمون وكلمات المرور المشفرة بالهاش وأسرار TOTP وأسرار التطبيقات، لذلك فالنسخة الاحتياطية هي نسخة قاعدة البيانات، ويجب أن تعاملها كما تعامل كلمات المرور نفسها:
docker compose exec -T postgres pg_dump -U keycloak -Fc keycloak > keycloak-$(date +%F).dumpوكان حجم الملف 263 KB. ولا تكتف بأخذ النسخة، وإنما جرب استعادتها، وهذا ما فعلناه في قاعدة بيانات جديدة بجانب الأصلية، ثم عددنا المستخدمين وبيانات الدخول Credentials:
docker compose exec postgres createdb -U keycloak kc_restore
docker compose exec -T postgres pg_restore -U keycloak -d kc_restore --no-owner < keycloak-2026-10-25.dump
docker compose exec postgres psql -U keycloak -d kc_restore -tc "select r.name, count(u.id) from realm r left join user_entity u on u.realm_id=r.id group by r.name"
docker compose exec postgres psql -U keycloak -d kc_restore -tc "select type, count(*) from credential group by type" arabroot | 3
master | 1
otp | 2
password | 3أما أمر kc.sh export الذي يصدر النطاق إلى ملف JSON فهو مفيد لنقل الإعداد بين بيئة الاختبار والإنتاج، ولكن التوثيق يوصي بإيقاف كل نسخ Keycloak قبل تشغيله حتى يكون التصدير متسقاً، لذلك لا تجعله نسختك الاحتياطية اليومية.
التحديث Upgrade إلى إصدار أحدث
- اقرأ ملاحظات الإصدار ودليل الترقية، وخاصة قسم Migration Changes عند الانتقال بين إصدارات فرعية مثل 26.7 إلى 26.8.
- خذ نسخة احتياطية بالأمر السابق، والسبب أن Keycloak يعدل الجداول في أول تشغيل للإصدار الجديد، ولا يوجد أمر للرجوع.
- غير الرقم في سطري
FROMفي الـ Dockerfile، ثمdocker build -t keycloak-custom:26.8.2 .وغير الـ Image في ملف Compose. - شغل
docker compose up -d keycloakوتابع السجل حتى سطرBootstrap completedوحالةhealthy، ثم سجل الدخول إلى تطبيق واحد على الأقل.
وتابع صفحة التنبيهات الأمنية للمشروع، أو اشترك في إشعارات الإصدارات Releases في GitHub، والسبب ما ذكرناه في البداية: إصلاحات أمنية كثيرة تصدر في الإصدارات التصحيحية، ومنها ما يمس المصادقة الثنائية نفسها.
مشكلات شائعة وحلولها
الرد 503 ورسالة «Bootstrap in progress»
Keycloak ما زال ينشئ الجداول أو يجهز النطاقات، وقد يطول ذلك دقائق في أول تشغيل على قرص بطيء، فانتظر حالة healthy ولا تعد تشغيل الـ Container خلالها.
كل تشغيل يبدأ بسطر «Changes detected in configuration. Updating the server image»
أنت تشغل الـ Image الرسمية بالأمر start، فيعيد Keycloak خطوة البناء في كل Container جديد، والحل هو الـ Image المبنية مسبقاً مع --optimized كما في قسم التثبيت.
صفحة الدخول تعرض «اسم المستخدم أو كلمة مرور غير صالحة.» قبل أن تكتب شيئاً
هناك خطوة في تدفق الدخول تحتاج إلى مستخدم معروف وتأتي قبل خطوة اسم المستخدم، وغالباً بسبب تعديل عبر الـ API أعاد الأولوية إلى صفر، فافتح التدفق في Authentication وتأكد أن Username Password Form هي الأولى. وإذا أغلقت الدخول على نفسك فلوحة الإدارة في نطاق master لا تتأثر بتدفقات نطاقك.
«Invalid parameter: redirect_uri»
العنوان الذي يرسله التطبيق لا يطابق حرفاً بحرف أحد عناوين Valid redirect URIs، ومنه الفرق بين http وhttps أو الشرطة المائلة في آخره، وهذا ما شرحناه في دليل OAuth.
«HTTPS required» عند الدخول من عنوان غير localhost
النطاق يطلب HTTPS لكل الطلبات الخارجية، وهذا صحيح ولا تعطله، وإنما ضع Keycloak خلف Reverse Proxy مع TLS، وتأكد من KC_PROXY_HEADERS حتى يعرف Keycloak أن الطلب الأصلي كان HTTPS.
الـ Container يتوقف ويظهر في السجل OutOfMemoryError
الحد الذي وضعته صغير على عدد المستخدمين والجلسات لديك، والـ Image تستخدم -XX:+ExitOnOutOfMemoryError فتتوقف بدل أن تبقى في حالة غير مستقرة، فارفع mem_limit إلى 1g أو أكثر وراقب الذاكرة بعدها.
كم تدفع لو اخترت خدمة تجارية؟
Keycloak مجاني بالكامل، وتكلفته الحقيقية هي السيرفر ووقت من يديره ويحدثه، وأما الخدمات السحابية المقابلة لهوية الموظفين Workforce Identity فتسعر بالمستخدم شهرياً، وهذه أسعارها من صفحاتها الرسمية بتاريخ 25 أكتوبر 2026:
| الخدمة | السعر | شروط |
|---|---|---|
| Okta Workforce Identity | Starter بـ 6 دولارات، وCore Essentials بـ 14 دولاراً، وEssentials بـ 17 دولاراً للمستخدم شهرياً | تدفع سنوياً، وأقل عقد سنوي 1,500 دولار |
| Microsoft Entra ID | P1 بـ 7 دولارات وP2 بـ 10 دولارات للمستخدم شهرياً | اشتراك سنوي |
فمؤسسة بـ 100 موظف تدفع لـ Okta Starter نحو 7,200 دولار في السنة، ولـ Entra ID P1 نحو 8,400 دولار، وفي المقابل تحصل على خدمة لا تديرها ولا تحدثها بنفسك. وإذا اخترت Keycloak وأردت دعماً تجارياً فـ Red Hat تقدم نسخة مدعومة اسمها Red Hat build of Keycloak ضمن اشتراكاتها، ولا تنشر سعرها.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- Keycloak يناسبك عندما تحتاج إلى نطاقات منفصلة، أو SAML مع أنظمة قديمة، أو ربط مع LDAP وActive Directory، أو تدفقات دخول تبنيها بنفسك، أما الفريق الصغير فقد يكفيه authentik أو أخف منه.
- ابن Docker Image خاصة بالأمر
kc.sh buildوشغلها بـ--optimized، فينزل وقت التشغيل من دقائق إلى نحو نصف دقيقة. - ضع حداً للذاكرة، فـ Java تأخذ 70٪ مما تراه، و768 MB كانت كافية لاختبار 500 طلب توكن دون خطأ.
- النطاق الجديد بلا حماية من التخمين وبلا سياسة لكلمات المرور وبلا حفظ للأحداث، والمصادقة الثنائية في التدفق الافتراضي اختيارية فعلياً، فعدل هذا كله قبل أن تربط أول تطبيق.
- العربية مدعومة مع RTL، ولكن ينقصها 91 رسالة في صفحة الدخول تعدلها من Realm overrides.
- تابع الإصدارات التصحيحية، فقد أصلح 26.8.1 وحده نحو عشرين ثغرة، منها تجاوز إلزام المصادقة الثنائية.
وفي مقالات قادمة بإذن الله سوف نقارن Keycloak مع authentik وAuthelia وZITADEL، ونشرح متى تختار كل واحد منها.
سجل التحديثات
- أكتوبر 2026: كتابة الدليل واختباره على Keycloak 26.8.1 مع PostgreSQL 17.6.