@lousho/build-ai-agent/auth عن السؤالين. تعطي المسار قائمة مرتبة من مدخلات المصادقة (jwt() أو oidc() أو basic() أو apiToken() أو anonymous() أو دالة من كتابتك)؛ فيُعيد أول مدخل يقبل الطلب هوية (principal)، وتدخل هذه الهوية في التشغيل، حيث تستطيع دوال model وinstructions وtools ونطاقات الذاكرة قراءتها.
قائمة المصادقة
تُنفَّذ المدخلات بالترتيب، ويفعل كل مدخل واحدًا من ثلاثة أشياء:
إذا تجاوزت كل المدخلات الطلب، يحصل على
401. والقائمة الفارغة ترفض كل الطلبات. أما المدخل الذي يرمي أي خطأ آخر فهو خلل برمجي: يحصل الطلب على 500 بجسم عام، ويُسجَّل الخطأ بـ console.error (ولا يُرسَل أبدًا).
لا ترمي الأدوات المساعدة المضمّنة أي خطأ عند بيانات اعتماد سيئة: كلمة مرور خاطئة أو رمز منتهي الصلاحية أو رمز مُصدَر لجمهور (audience) آخر، كلها تتجاوز الطلب، فتُتاح الفرصة للمدخل التالي (يعمل مدخلا jwt() لمُصدِرَين مختلفين)، وينتهي الطلب الذي لا يقبله أحد بالـ 401 نفسه.
الـ Principal هو:
الأدوات المساعدة
تُنشأ كل الأدوات المساعدة مرة واحدة عند بدء التشغيل، وتتحقق من خياراتها حينها: الأداة التي لا يمكنها العمل ترميLOUSHO_AUTH_CONFIG_INVALID بدلًا من أن ترفض كل الطلبات لاحقًا.
jwt()
تقبل Authorization: Bearer <JWT> الموقّع بالمفتاح الذي تضبطه. أعطِ مصدر مفتاح واحدًا بالضبط:
- يجب أن تكون قيمة
algفي الترويسة إحدى قيمalgorithms؛ ويُرفضnoneدائمًا، وكذلك الرمز الذي تعلن ترويسته امتدادًا بـcrit. - يجب أن تناسب الخوارزمية المفتاح: لا يُستخدم المفتاح العام أبدًا كسر HMAC مهما قالت ترويسة الرمز، ولا توفّر مجموعة المفاتيح مفتاح HMAC أبدًا.
- الحقل
expمطلوب؛ ويُعمل بـnbfوiat. وتسمح الثلاثة بفارق ساعة قدرهclockToleranceSec(الافتراضي 60، والحد الأقصى 300). - يجب أن يساوي
issإحدى قيمissuerتمامًا (دون تسامح مع الشرطة المائلة الأخيرة)، عندما تضبطissuer. - يجب أن يحتوي
aud(نصًّا أو قائمة) على إحدى قيمaudience. والخيارaudienceمطلوب؛ مرّرallowAnyAudience: trueلتخطّي هذا الفحص عن قصد. - تُجلب مجموعة المفاتيح بمهلة 5 ثوانٍ، وتُخزَّن مؤقتًا 10 دقائق، وتُعاد جلبها مرة واحدة كل 30 ثانية على الأكثر حين يذكر رمز
kidلا تعرفه (وتُعاد محاولة الجلب الفاشل بالحد الزمني نفسه البالغ 30 ثانية). وعنوانها إعداد فقط: تُتجاهل ترويساتjkuوx5uوjwkفي الرمز.
{ id: sub, type: 'user', authenticator: 'jwt', issuer: iss, claims }؛ والرمز الذي لا يحوي sub يُتجاوز.
oidc()
هي jwt() تأتي مفاتيحها من مزوّد OpenID Connect. تجلب <issuer>/.well-known/openid-configuration مرة واحدة (أو discoveryUrl)، وتشترط أن يساوي issuer في الوثيقة قيمتك تمامًا، وتستخدم jwks_uri منها (ويجب أن يكون https). الخوارزميات الافتراضية RS256 وES256؛ ولا يُسمح بـ HMAC.
authenticator: 'oidc'، وiss الخاص بالمزوّد في issuer.
basic()
بيانات اعتماد HTTP Basic. تُطبَّع أسماء المستخدمين وكلمات المرور بصيغة NFC وتُقارَن في زمن ثابت، ويُقارَن كل مستخدم مضبوط في كل طلب، فيستغرق المستخدم المجهول الوقت نفسه الذي تستغرقه كلمة المرور الخاطئة. ويحمل الرد 401 الترويسة WWW-Authenticate: Basic realm="<realm>", charset="UTF-8" لتعرض المتصفحات نافذة تسجيل الدخول.
basic() إلا عبر HTTPS: فكلمة المرور تنتقل مع كل طلب.
apiToken() وanonymous()
تقبل apiToken(token, { id }) الترويسة Authorization: Bearer <token> (تُقارَن في زمن ثابت) بوصفها الهوية الخدمية { id: id ?? 'api-token', type: 'service', authenticator: 'api-token' }. وهي ما كان يعنيه النص الرمزي دائمًا: createRouteHandler({ auth: '<token>' }) وLOUSHO_API_TOKEN هما apiToken().
تقبل anonymous() الجميع بالهوية { id: 'anonymous', type: 'user', authenticator: 'anonymous' }: ضعها أخيرًا في المسارات التي تخدم المستخدمين المسجّلين والمجهولين معًا، واقرأ principal.authenticator أثناء التشغيل.
مدخل من كتابتك
أي دالة تأخذRequest تصلح مدخلًا. أعلِن التحديات (challenges) التي تردّ بها بالحالة 401، وإلا أعلنت Bearer:
الحالتان 401 و403 والترويسة WWW-Authenticate
- لم يقبل أحد الطلب (أو رمى مدخل
AuthError(401)): تُعاد401مع{ "error": "Unauthorized" }وترويسةWWW-Authenticateواحدة لكل تحدٍّ مميّز في القائمة، بالترتيب (BearerلـjwtوoidcوapiToken؛ وBasic realm="..."لـbasic). - رمى مدخل
AuthError(403): تُعاد403مع{ "error": "Forbidden" }. - فشل مدخل بطريقة أخرى: تُعاد
500مع{ "error": "Internal Server Error" }.
401 نفسها. ويحمل كل ردّ مصادقة الترويسة Cache-Control: no-store. ولا تُسجَّل الرموز وكلمات المرور أبدًا.
أين يعمل
تحتاج
oidc() وjwt({ jwksUrl }) إلى fetch صادر إلى مجموعة المفاتيح، وهو متاح في كل بيئات التشغيل المذكورة. وفي Worker مكتوب يدويًا، استدعِ createRouteHandler أو routeAuth() بنفسك مع أي أداة مساعدة.
createRouteHandler
يقبل auth القائمة، أو مدخلًا واحدًا، أو نص رمز، أو (كما في السابق) دالة تُعيد قيمة منطقية. تقبل true الطلب بالهوية { id: 'anonymous', type: 'user', authenticator: 'custom' }، وتعني false الحالة 401. وبلا auth يكون المسار مفتوحًا، وفي بيئة الإنتاج (NODE_ENV=production) يُسجَّل تحذير واحد. والمسار GET <basePath>/health ليس خلف auth أبدًا. وتصل الهوية المقبولة إلى كل المسارات: POST <basePath> و/chat والموافقات ونقطة useChat. راجع Next.js.
خادم Node وauth.ts
يأخذ createDeployedServer(agent, { auth }) القائمة نفسها (أو مدخلًا واحدًا). وعند ضبط LOUSHO_API_TOKEN، تُلحَق apiToken(LOUSHO_API_TOKEN) بعد مدخلاتك، فيظل رمز المشغّل صالحًا. أما القنوات تحت /channels/<name> فليست خلف القائمة: فهي تتحقق من طلباتها بنفسها.
في مجلد الوكيل، ضع القائمة في auth.ts (أو auth.js) بجوار agent.ts:
lousho build --target=node-server (أو docker) ويستخدمها الخادم المبني (تُعيدها resolveAgentDir() باسم auth؛ ويذكر البيان auth: true). ومع وجود auth.ts، لا يُستخدم الرمز المضمَّن عبر adapter.scaffold(..., { auth: { token } })؛ أما LOUSHO_API_TOKEN فيبقى مستخدمًا. ويقدّم الهدف cloudflare-worker ملفات المواصفات فقط ويُبقي رمز LOUSHO_API_TOKEN؛ راجع النشر.
قراءة الهوية أثناء التشغيل
تكون الهوية في سياق التشغيل بجوارsessionId وmetadata:
principal منفصل عن metadata عن قصد: فـ metadata هو ما أرسله المستدعي، وprincipal هو ما تحقّقت منه المصادقة. والتشغيل الديناميكي المُستأنف من نقطة الحفظ يحتفظ بالهوية التي بدأ بها. وتضبط قناتا Slack وDiscord المُرسِل هويةً (authenticator: 'slack' / 'discord'). أما الأدوات وسياسات الموافقة والتشغيلات الساكنة الموقوفة فلا ترى الهوية بعد.
ملاحظات أمنية
- لا تتحقق مصادقة المسار ممن يملك الجلسة. يستطيع أي مستدعٍ يجتاز المصادقة ويعرف معرّف جلسة أن يقرأ نصّها (
GET /chat/:id) ويتابعها ويبتّ في موافقاتها المعلّقة. استخدم معرّفات جلسات لا يمكن تخمينها، أو اشتقّها من الهوية في جانبك، أو تحقق من الملكية في مسارك قبل استدعاء المعالج. - لا تستخدم
basic()إلا عبر HTTPS؛ والرمز الحامل (bearer) كلمة مرور أيضًا، لذا أنهِ TLS أمام أي خادم ليس على localhost. - أبقِ
issuerوaudienceمضبوطين فيjwt(): وإلا قُبل هنا رمز صُدر لخدمة أخرى من خدماتك.