الكتابات/tutorial/2026/08
Tutorial12 أغسطس 2026·30 دقيقة

تكامل نفاذ (OAuth2/OIDC) مع تطبيقك بلغة TypeScript

دليل تطبيقي خطوة بخطوة لتكامل منصة النفاذ الوطني الموحد في تطبيقك بلغة TypeScript عبر بروتوكول OAuth2/OIDC. يشمل التسجيل في خدمة iDART، بناء تدفق التفويض، التحقق من رمز الهوية، استخراج بيانات الهوية الوطنية، وخيار جسر Keycloak.

كل تطبيق يستهدف السوق السعودي يصطدم في نقطة ما بالجدار نفسه: مستخدموك يحتاجون إلى التحقق من هوياتهم الوطنية، والمعيار الذهبي لذلك هو نفاذ — منصة الهوية الرقمية الوطنية التي تشغّلها سدايا، والتي يحملها اليوم 16.3 مليون سعودي على هواتفهم.

هذا ليس تكامل "تسجيل الدخول بـ Google". نفاذ مرتبط بالسجلات المدنية الموثّقة لدى مركز المعلومات الوطني. حين يصادق مستخدم عبر نفاذ، تحصل على رقم هويته الوطنية، واسمه القانوني الكامل، ورقم جواله المُتحقَّق منه، وتاريخ ميلاده — كلها مؤكّدة من سجلات NIC. لمنصات العقار والتمويل والموارد البشرية والصحة والخدمات الحكومية، هذا هو التكامل الوحيد الذي يُعتدّ به تنظيمياً.

المشكلة: لا توجد بوابة مطوّرين مفتوحة مع دليل بدء سريع. البحث في الإنترنت يُعيد صفحات حكومية وبلاغات سدايا وطلبات على مستقل تطلب "تكامل نفاذ في موقعنا". هذا الدليل يسدّ هذه الفجوة.

المتطلبات الأساسية

قبل البدء، تأكّد من توفّر:

  • Node.js 20 أو أحدث مع TypeScript 5.x
  • مشروع Next.js 14 أو أي إطار TypeScript خادم (Express، Fastify، Hono)
  • طلب API معتمد على نفاذ (يُشرح في الخطوة الأولى)
  • فهم أساسي لتدفق OAuth2 (authorization code flow)
  • نقطة نهاية HTTPS لعنوان الاسترداد — نفاذ يرفض http://localhost في البيئة الإنتاجية

ما الذي ستبنيه

تدفق مصادقة نفاذ كاملاً لتطبيق Next.js:

  1. زر "تسجيل الدخول بنفاذ" يُعيد توجيه المستخدم للـ SSO الوطني
  2. مسار استرداد OAuth2 يبادل رمز التفويض برموز الوصول
  3. طبقة تحقق JWT تتحقق من توقيع رمز الهوية
  4. معالج جلسة يستخرج بيانات الهوية الموثّقة ويحفظها
  5. مسار محمي لا يسمح إلا للمستخدمين ذوي هوية سعودية مؤكّدة

كيف يعمل نفاذ

نفاذ يستخدم خدمة iDART (Identity Access and Rights Token) — مزوّد الهوية المتوافق مع OIDC من سدايا. التدفق معياري OAuth2 بفارق واحد جوهري: بدلاً من إدخال كلمة مرور، يفتح المستخدم تطبيق نفاذ على هاتفه ويوافق على طلب الدخول ببصمة أو رقم سري.

تطبيقك               iDART / نفاذ OIDC            هاتف المستخدم
   |                        |                            |
   |-- رابط التفويض ------> |                            |
   |                        |-- إشعار فوري -----------> |
   |                        |              [موافقة]      |
   |                        | <---- تأكيد بيومتري ------|
   | <-- code (redirect) ---|                            |
   |                        |                            |
   |-- code + client_secret>|                            |
   | <-- access_token + id_token + refresh_token --------|
   |                        |                            |
   |-- طلب UserInfo ------> |                            |
   | <-- NID + الاسم + الجوال|                            |

التفصيل التقني الحاسم: نفاذ مصادق يعتمد الهاتف أساساً. تطبيقك يجب أن يتعامل مع الموافقة غير المتزامنة — قد يستغرق المستخدم 30 إلى 60 ثانية للتأكيد على هاتفه. تحتاج آلية polling أو webhook للتعامل مع هذا بصورة سلسة.

الخطوة الأولى: التقدم للحصول على صلاحية الـ API

التكامل المباشر مع نفاذ يستلزم طلباً معتمداً من سدايا ومركز المعلومات الوطني:

  1. سجّل منصّتك عبر my.gov.sa ضمن "إدارة التطبيقات"
  2. قدّم وصف المنصة وعناوين الاسترداد وحالة الاستخدام
  3. سدايا تراجع وتعتمد الطلب (عادةً 5-15 يوم عمل للقطاع الخاص)
  4. تحصل على client_id وتعليمات للحصول على client_secret

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

المسار البديل — رابط: إذا احتجت وقتاً أسرع للإطلاق، منصة رابط (legacy.rabet.sa) توفّر وصولاً وسيطاً لنفاذ عبر بيانات اعتماد أبشر. هذا يعمل لكنه يضيف تبعية لطرف ثالث وآلية مصادقة أقدم. التكامل المباشر مع iDART هو المسار الموصى به للإنتاج.

الخطوة الثانية: إعداد المشروع

ثبّت الحزم المطلوبة:

npm install openid-client jose zod
npm install -D @types/node
  • openid-client: مكتبة OIDC معتمدة (تتعامل مع discovery وPKCE وتبادل الرموز)
  • jose: التحقق من JWT مع دعم JWKS
  • zod: التحقق من صحة مطالبات الهوية في وقت التشغيل

أنشئ ملف متغيرات البيئة:

# .env.local
NAFATH_CLIENT_ID=client_id_من_سدايا
NAFATH_CLIENT_SECRET=client_secret_الخاص_بك
NAFATH_ISSUER=https://iam.gov.sa
NAFATH_REDIRECT_URI=https://تطبيقك.sa/api/auth/nafath/callback
SESSION_SECRET=سلسلة_عشوائية_طويلة_لتوقيع_الجلسة

ملاحظة: سدايا قد تزوّدك بعنوان issuer مختلف خاص بطلبك المعتمد. استخدم دائماً العنوان من خطاب الاعتماد — نقطة اكتشاف iDART تتّبع النمط ISSUER_URL/.well-known/openid-configuration.

الخطوة الثالثة: إعداد عميل OIDC

أنشئ وحدة عميل OIDC مشتركة تُخزّن الإعداد المكتشَف مؤقتاً:

// lib/nafath-oidc.ts
import { Issuer, Client, generators } from "openid-client";
 
let nafathClient: Client | null = null;
 
export async function getNafathClient(): Promise<Client> {
  if (nafathClient) return nafathClient;
 
  const issuer = await Issuer.discover(process.env.NAFATH_ISSUER!);
 
  nafathClient = new issuer.Client({
    client_id: process.env.NAFATH_CLIENT_ID!,
    client_secret: process.env.NAFATH_CLIENT_SECRET!,
    redirect_uris: [process.env.NAFATH_REDIRECT_URI!],
    response_types: ["code"],
    token_endpoint_auth_method: "client_secret_basic",
  });
 
  return nafathClient;
}
 
export function generatePKCE() {
  const codeVerifier = generators.codeVerifier();
  const codeChallenge = generators.codeChallenge(codeVerifier);
  return { codeVerifier, codeChallenge };
}

استدعاء Issuer.discover() يجلب وثيقة اكتشاف OIDC ويملأ جميع نقاط النهاية تلقائياً. هذا يعني أن كودك يتكيّف إذا حدّثت سدايا عناوين نقاط النهاية.

الخطوة الرابعة: بناء رابط التفويض

// app/api/auth/nafath/route.ts
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getNafathClient, generatePKCE } from "@/lib/nafath-oidc";
import { generators } from "openid-client";
 
export async function GET() {
  const client = await getNafathClient();
  const { codeVerifier, codeChallenge } = generatePKCE();
  const state = generators.state();
  const nonce = generators.nonce();
 
  const cookieStore = cookies();
  cookieStore.set("nafath_code_verifier", codeVerifier, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 600,
    path: "/",
  });
  cookieStore.set("nafath_state", state, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 600,
    path: "/",
  });
  cookieStore.set("nafath_nonce", nonce, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 600,
    path: "/",
  });
 
  const authorizationUrl = client.authorizationUrl({
    scope: "openid profile national_id phone",
    state,
    nonce,
    code_challenge: codeChallenge,
    code_challenge_method: "S256",
    acr_values: "urn:nafath:iam:push",
  });
 
  return NextResponse.redirect(authorizationUrl);
}

بشأن النطاقات (Scopes): نطاقات OIDC لنفاذ تُحدَّد بما اعتمدته سدايا لتطبيقك. النطاقات الشائعة:

  • openid — مطلوب دائماً
  • profile — الاسم القانوني الكامل وتاريخ الميلاد
  • national_id — رقم الهوية الوطنية (معظم التطبيقات تحتاجه)
  • phone — رقم الجوال المُتحقَّق من NIC
  • address — العنوان المُسجَّل (يستلزم اعتماداً منفصلاً)

الخطوة الخامسة: معالجة استدعاء الاسترداد

// app/api/auth/nafath/callback/route.ts
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getNafathClient } from "@/lib/nafath-oidc";
import { verifyNafathToken } from "@/lib/nafath-verify";
import { createSession } from "@/lib/session";
 
export async function GET(request: NextRequest) {
  const cookieStore = cookies();
  const codeVerifier = cookieStore.get("nafath_code_verifier")?.value;
  const expectedState = cookieStore.get("nafath_state")?.value;
  const nonce = cookieStore.get("nafath_nonce")?.value;
 
  if (!codeVerifier || !expectedState || !nonce) {
    return NextResponse.redirect("/auth/error?reason=missing_session");
  }
 
  try {
    const client = await getNafathClient();
    const params = client.callbackParams(request.url);
 
    const tokenSet = await client.callback(
      process.env.NAFATH_REDIRECT_URI!,
      params,
      {
        code_verifier: codeVerifier,
        state: expectedState,
        nonce,
      }
    );
 
    if (!tokenSet.id_token) {
      throw new Error("لم يصل رمز الهوية في الاستجابة");
    }
 
    const identity = await verifyNafathToken(tokenSet.id_token, nonce);
 
    const sessionToken = await createSession({
      nationalId: identity.national_id,
      fullName: identity.name,
      phone: identity.phone_number,
      birthdate: identity.birthdate,
      accessToken: tokenSet.access_token!,
      expiresAt: tokenSet.expires_at!,
    });
 
    cookieStore.delete("nafath_code_verifier");
    cookieStore.delete("nafath_state");
    cookieStore.delete("nafath_nonce");
 
    const response = NextResponse.redirect("/dashboard");
    response.cookies.set("session", sessionToken, {
      httpOnly: true,
      secure: true,
      sameSite: "strict",
      maxAge: 60 * 60 * 8,
      path: "/",
    });
 
    return response;
  } catch (error) {
    console.error("خطأ في استدعاء نفاذ:", error);
    return NextResponse.redirect("/auth/error?reason=callback_failed");
  }
}

الخطوة السادسة: التحقق من رمز الهوية

لا تثق برمز هوية دون التحقق من توقيعه. iDART ينشر نقطة نهاية JWKS — استخدمها:

// lib/nafath-verify.ts
import { createRemoteJWKSet, jwtVerify } from "jose";
import { z } from "zod";
 
const NafathClaimsSchema = z.object({
  sub: z.string(),
  national_id: z.string().regex(/^\d{10}$/),
  name: z.string().min(1),
  phone_number: z.string().optional(),
  birthdate: z.string().optional(),
  iss: z.string(),
  aud: z.union([z.string(), z.array(z.string())]),
  exp: z.number(),
  iat: z.number(),
  nonce: z.string().optional(),
});
 
export type NafathClaims = z.infer<typeof NafathClaimsSchema>;
 
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
 
function getJWKS() {
  if (!jwks) {
    jwks = createRemoteJWKSet(
      new URL(`${process.env.NAFATH_ISSUER}/jwks`)
    );
  }
  return jwks;
}
 
export async function verifyNafathToken(
  idToken: string,
  nonce: string
): Promise<NafathClaims> {
  const { payload } = await jwtVerify(idToken, getJWKS(), {
    issuer: process.env.NAFATH_ISSUER!,
    audience: process.env.NAFATH_CLIENT_ID!,
  });
 
  if (payload.nonce !== nonce) {
    throw new Error("عدم تطابق nonce — هجوم إعادة تشغيل محتمل");
  }
 
  const now = Math.floor(Date.now() / 1000);
  if ((payload.exp ?? 0) < now) {
    throw new Error("انتهت صلاحية رمز الهوية");
  }
 
  const claims = NafathClaimsSchema.parse(payload);
  return claims;
}

المطالبة national_id تحتوي على رقم هوية المستخدم السعودية — رقم مكوّن من 10 أرقام يبدأ بـ 1 للمواطنين السعوديين وبـ 2 للمقيمين (الإقامة). يمكنك استخدامه كمفتاح في قاعدة بياناتك، أو للمطابقة مع سجلات التأمينات الاجتماعية أو أي نظام آخر يستخدم الهوية الوطنية كمعرّف.

الخطوة السابعة: إدارة الجلسات

// lib/session.ts
import { SignJWT, jwtVerify } from "jose";
 
const SESSION_SECRET = new TextEncoder().encode(
  process.env.SESSION_SECRET!
);
 
export interface SessionPayload {
  nationalId: string;
  fullName: string;
  phone?: string;
  birthdate?: string;
  accessToken: string;
  expiresAt: number;
}
 
export async function createSession(payload: SessionPayload): Promise<string> {
  return new SignJWT(payload as Record<string, unknown>)
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("8h")
    .sign(SESSION_SECRET);
}
 
export async function getSession(token: string): Promise<SessionPayload | null> {
  try {
    const { payload } = await jwtVerify(token, SESSION_SECRET);
    return payload as unknown as SessionPayload;
  } catch {
    return null;
  }
}

في بيئة الإنتاج، استخدم مخزن جلسات من جانب الخادم (Redis أو قاعدة بيانات) بدلاً من تشفير كل شيء في الكوكي — خاصةً إذا احتجت إلى إلغاء الجلسات عند تسجيل خروج المستخدم أو تعليق حسابه في نفاذ.

الخطوة الثامنة: حماية المسارات

// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { getSession } from "@/lib/session";
 
const PROTECTED_PATHS = ["/dashboard", "/account", "/services"];
 
export async function middleware(request: NextRequest) {
  const isProtected = PROTECTED_PATHS.some((path) =>
    request.nextUrl.pathname.startsWith(path)
  );
 
  if (!isProtected) return NextResponse.next();
 
  const sessionToken = request.cookies.get("session")?.value;
  if (!sessionToken) {
    return NextResponse.redirect(new URL("/auth/login", request.url));
  }
 
  const session = await getSession(sessionToken);
  if (!session) {
    const response = NextResponse.redirect(new URL("/auth/login", request.url));
    response.cookies.delete("session");
    return response;
  }
 
  const now = Math.floor(Date.now() / 1000);
  if (session.expiresAt < now) {
    return NextResponse.redirect(new URL("/auth/nafath", request.url));
  }
 
  return NextResponse.next();
}
 
export const config = {
  matcher: ["/dashboard/:path*", "/account/:path*", "/services/:path*"],
};

الخطوة التاسعة: بديل جسر Keycloak

نشرت سدايا مكوّناً مفتوح المصدر لـ Keycloak على oss.dga.gov.sa. إذا كانت مؤسستك تشغّل Keycloak مسبقاً (شائع في الشركات الكبرى والجهات الحكومية)، فهذا أسرع مسار:

المعمارية:

تطبيقك --> Keycloak --> iDART نفاذ --> هاتف المستخدم
      (OIDC/SAML)   (مكوّن iDART)

من تطبيق TypeScript الخاص بك، تتكامل مع Keycloak (وليس مع نفاذ مباشرةً) باستخدام OIDC المعياري. Keycloak يتولى تفاصيل بروتوكول نفاذ ويقدّم لك هوية موحّدة ومعيارية. هذا المسار يمنحك أيضاً إدارة المستخدمين في Keycloak والتحكم في الجلسات وسجلات التدقيق.

الاختبار في بيئة Sandbox

توفّر سدايا بيئة Sandbox للتطوير. في sandbox:

  • استخدم أرقام هوية اختبارية مُزوَّدة في توثيق المطوّر
  • تطبيق نفاذ له مفتاح "sandbox" — وجّه المختبرين لتفعيله
  • صلاحية الرموز أقصر (5 دقائق) لتشجيع المعالجة الصحيحة للتجديد
  • رموز sandbox لا تصل إلى بيانات السجل المدني في الإنتاج

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

const errorCode = params.error;
if (errorCode === "access_denied") {
  return NextResponse.redirect("/auth/error?reason=nafath_rejected");
}

استكشاف الأخطاء

"redirect_uri_mismatch" — عنوان الاسترداد في طلب التفويض يجب أن يتطابق تماماً مع ما سُجِّل لدى سدايا. اختلاف شرطة مائلة في النهاية كافٍ للفشل.

"invalid_client" — تحقق مرتين من client_id وclient_secret. بطريقة مصادقة client_secret_basic ترسَل مشفّرة بـ Base64 في الترويسة، وليس في جسم الطلب.

"nonce_expired" — المستخدم تأخّر في الموافقة. نفّذ عداداً تنازلياً في صفحة الانتظار وأعِد توجيهاً تلقائياً بعد 90 ثانية.

فشل جلب JWKS — خزِّن JWKS محلياً بوقت صلاحية 24 ساعة. مفاتيح نفاذ تتغيّر دورياً؛ إذا فشل التحقق بعد فترة عمل صحيحة، امسح ذاكرة JWKS المؤقتة وأعِد الجلب.

"scope_not_approved" — طلبت نطاقاً (مثل national_id) لم يُمنح في طلب التطبيق لديك. راجع قائمة النطاقات المعتمدة في بوابة المطوّر.

قائمة تحقق الأمان

قبل الإطلاق الإنتاجي، تأكّد من:

  • PKCE مُفعَّل (code_challenge_method: "S256") — يمنع اعتراض رمز التفويض
  • Nonce مُتحقَّق منه في رمز الهوية — يمنع هجمات إعادة التشغيل
  • State مُتحقَّق منه — يمنع CSRF على الاسترداد
  • توقيع رمز الهوية مُتحقَّق منه ضد JWKS — لا تتخطَّ هذه الخطوة أبداً
  • المطالبة exp مُفحوصة — ارفض الرموز المنتهية الصلاحية
  • عنوان الاسترداد HTTPS فقط — نفاذ يرفض العناوين غير الآمنة
  • كوكيز الجلسة تحمل httpOnly وsecure وsameSite: "strict"
  • رمز الوصول لا يُكشف للمتصفح — احتفظ به في جانب الخادم فقط
  • تجديد الرمز يُعالَج قبل انتهاء الصلاحية — تجنّب فشل المصادقة في منتصف الجلسة

أدلة منصات حكومية سعودية ذات صلة

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

الخطوات التالية

بعد تشغيل مصادقة نفاذ:

  1. أضِف تسجيل الخروج: استدعِ نقطة نهاية إنهاء الجلسة في iDART لإلغاء جلسة نفاذ، وليس فقط الكوكي المحلي
  2. عالج تجديد الرمز: استخدم رمز التجديد لتمديد الجلسات دون إعادة المصادقة
  3. خزّن الهوية الوطنية كمفتاح خارجي: اعتمِد على sub أو national_id كمثبّت هوية رئيسي عبر خدماتك
  4. سجّلات التدقيق: سجّل كل حدث مصادقة مع الطابع الزمني وهاش الهوية الوطنية (وليس نصاً صريحاً) — مطلوب للمنصات المنظَّمة
  5. فكّر في Keycloak: إذا كان لديك تطبيقات متعددة تحتاج نفاذ، جسر Keycloak يتفادى تنفيذ هذا التدفق في كل تطبيق منفرداً

الخلاصة

تكامل نفاذ يستحق عملية التسجيل. حين يصادق مستخدم عبر نفاذ، تحصل على هوية موثَّقة لا توفّرها أي طريقة تسجيل دخول أخرى في المملكة. تدفق OAuth2/OIDC معياري — الأجزاء غير المعيارية الوحيدة هي تأكيد الدفع عبر الهاتف، وعملية اعتماد النطاقات مع سدايا، والمطالبات المدنية في الرمز.

توثيق تكامل نفاذ بالعربية للمطوّرين شبه معدوم. إذا كنت تبني منصات تستهدف السوق السعودي، التكامل مع نفاذ قبل منافسيك ميزة تقنية حقيقية.


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