Kodax

OAuth و Social Login — Authentication & Security

OAuth و تسجيل الدخول الاجتماعي

"سجّل الدخول بـ Google" يعني أن Google تتحقق من هوية المستخدم، وتطبيقك يثق بنتيجتها — دون أن يرى كلمة مرور Google أبداً.

مصطلحات

| المصطلح | المعنى |

|---|---|

| OAuth 2.0 | بروتوكول تفويض: السماح لتطبيقك بالوصول لموارد المستخدم عند مزود (تقويمه، مستودعاته) |

| OpenID Connect (OIDC) | طبقة مصادقة فوق OAuth: تعطيك id_token يثبت من هو المستخدم |

| Client ID / Secret | هوية تطبيقك عند المزود؛ السر على الخادم فقط |

| Redirect URI | العنوان الذي يعود إليه المستخدم بعد الموافقة؛ يُسجَّل مسبقاً لدى المزود |

| Scopes | الصلاحيات المطلوبة: openid email profile — اطلب الحد الأدنى |

تدفق رمز التفويض (Authorization Code)

1) المستخدم يضغط "الدخول بـ Google" ← تطبيقك يحوّله إلى Google مع: client_id، redirect_uri، scope، state، code_challenge
2) المستخدم يسجل الدخول في Google ويوافق
3) Google تعيده إلى redirect_uri ومعه ?code=...&state=...
4) خادمك يتحقق من state، ويستبدل code (مع client_secret و code_verifier) بالرموز — اتصال خادم-لخادم
5) خادمك يقرأ هوية المستخدم، ينشئ/يربط الحساب، ويبدأ جلسته الخاصة

- state: قيمة عشوائية تُحفظ قبل التحويل وتُقارن عند العودة ← تمنع CSRF في التدفق.

- PKCE (code_challenge/code_verifier): يمنع من يعترض الـ code من استخدامه. إلزامي للتطبيقات الجوالة وتطبيقات المتصفح، ومُستحسن للجميع.

- التدفق الضمني القديم (Implicit، الرمز في الرابط) مُهمل — لا تستخدمه.

تسليم الجلسة للواجهة

بعد الخطوة 5، لا تضع الرمز في رابط إعادة التوجيه (?token=...): يبقى في سجل المتصفح ويُرسل في ترويسة Referer ويظهر في سجلات الخوادم. الأفضل: ملف تعريف ارتباط HttpOnly (كما في المثال الكامل بعد التصحيح)، أو رمز لمرة واحدة قصير جداً تستبدله الواجهة بطلب POST.

ربط الحسابات

- اربط بالمعرّف الثابت من المزود (sub أو profile.id)، لا بالبريد وحده.

- إذا وُجد حساب بنفس البريد مسجل بكلمة مرور، لا تدمجهما تلقائياً إلا إذا كان البريد موثّقاً لدى المزود (email_verified) — وإلا يستطيع مهاجم إنشاء حساب عند مزود ببريدك وأخذ حسابك.

Passport.js

المثال الكامل يستخدم Passport مع استراتيجيات Google و GitHub — تخفي تفاصيل الخطوات 1–4. مكتبات أحدث مثل Auth.js، أو خدمات مثل Supabase Auth و Firebase Auth، تقدم الشيء نفسه بإعداد أقل.

تمرين

سجّل تطبيق OAuth تجريبياً في GitHub (Settings ← Developer settings)، وحدد Redirect URI محلياً، وشغّل المثال. افحص الطلبات في أدوات المطور: أين يظهر state؟ وأين يظهر code؟ ولماذا لا يظهر client_secret أبداً في المتصفح؟

// npm install passport passport-google-oauth20

const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;

// ===== Google OAuth Strategy =====
passport.use(new GoogleStrategy({
    clientID: process.env.GOOGLE_CLIENT_ID,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    callbackURL: '/api/auth/google/callback'
  },
  async (accessToken, refreshToken, profile, done) => {
    try {
      // البحث عن المستخدم أو إنشاؤه
      let user = await User.findOne({ googleId: profile.id });
      
      if (!user) {
        // مستخدم جديد
        user = await User.create({
          googleId: profile.id,
          name: profile.displayName,
          email: profile.emails[0].value,
          avatar: profile.photos[0].value,
          provider: 'google'
        });
      }
      
      return done(null, user);
    } catch (err) {
      return done(err, null);
    }
  }
));

// ===== Routes =====

// بدء OAuth flow
app.get('/api/auth/google',
  passport.authenticate('google', { 
    scope: ['profile', 'email'] 
  })
);

// Callback بعد موافقة المستخدم
app.get('/api/auth/google/callback',
  passport.authenticate('google', { session: false }),
  (req, res) => {
    // إنشاء JWT
    const token = generateToken(req.user);
    
    // إعادة التوجيه للـ Frontend مع الـ Token
    // لا نضع الرمز في الرابط (يتسرب في السجل والـ Referer وسجلات الخوادم): ملف تعريف ارتباط HttpOnly
    res.cookie('accessToken', token, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 15 * 60 * 1000 });
    res.redirect(`${process.env.FRONTEND_URL}/auth/callback`);
  }
);

// ===== GitHub OAuth =====
const GitHubStrategy = require('passport-github2').Strategy;

passport.use(new GitHubStrategy({
    clientID: process.env.GITHUB_CLIENT_ID,
    clientSecret: process.env.GITHUB_CLIENT_SECRET,
    callbackURL: '/api/auth/github/callback'
  },
  async (accessToken, refreshToken, profile, done) => {
    let user = await User.findOne({ githubId: profile.id });
    
    if (!user) {
      user = await User.create({
        githubId: profile.id,
        name: profile.displayName || profile.username,
        email: profile.emails?.[0]?.value,
        avatar: profile.photos?.[0]?.value,
        provider: 'github'
      });
    }
    
    return done(null, user);
  }
));

app.get('/api/auth/github',
  passport.authenticate('github', { scope: ['user:email'] })
);

app.get('/api/auth/github/callback',
  passport.authenticate('github', { session: false }),
  (req, res) => {
    const token = generateToken(req.user);
    res.redirect(`${process.env.FRONTEND_URL}/auth/callback?token=${token}`);
  }
);
اختبر فهمك