Node.jsBackendFull-Stack

OAuth & Social Login with Passport.js in Node.js

TT
TopicTrick Team
OAuth & Social Login with Passport.js in Node.js

OAuth & Social Login with Passport.js in Node.js

Social login removes the biggest barrier to user registration: the password. Users already have Google and GitHub accounts and trust them. Clicking "Login with Google" is faster and safer than creating yet another password they will forget.

This module adds Google and GitHub OAuth to the JWT API from Module 18. By the end you will have a complete social login flow, provider account linking, and a clean abstraction that makes adding more providers trivial.

This is Module 21 of the Node.js Full‑Stack Developer course.


How the OAuth Flow Works

text
Browser                  Your API                  Google
  │                          │                        │
  │── GET /auth/google ──────▶│                        │
  │                          │── redirect ────────────▶│
  │                          │   (with state + scope)  │
  │◀────── redirect ──────────────────────────────────│
  │                          │                        │
  │── User approves ─────────────────────────────────▶│
  │                          │◀── code ───────────────│
  │                          │                        │
  │                          │── POST /token ─────────▶│
  │                          │◀── access_token ────────│
  │                          │                        │
  │                          │── GET /userinfo ────────▶│
  │                          │◀── profile ─────────────│
  │                          │                        │
  │                          │ find or create user    │
  │                          │ issue JWT              │
  │◀── redirect + JWT ────────│                        │

The state parameter is a random value Passport generates to prevent CSRF attacks on the callback endpoint.


Installing Dependencies

bash
npm install passport passport-google-oauth20 passport-github2

User Model — OAuth Provider Support

Extend the user model to store multiple OAuth providers:

js
// features/users/users.model.js
import mongoose from 'mongoose';

const providerSchema = new mongoose.Schema({
  name:       { type: String, required: true },  // 'google', 'github'
  providerId: { type: String, required: true },  // the ID from the provider
}, { _id: false });

const userSchema = new mongoose.Schema(
  {
    name:      { type: String, required: true, trim: true },
    email:     { type: String, required: true, unique: true, lowercase: true },
    password:  { type: String, select: false },   // optional — OAuth users may have no password
    role:      { type: String, enum: ['user', 'admin'], default: 'user' },
    avatar:    { type: String },
    providers: { type: [providerSchema], default: [] },
    tokenVersion: { type: Number, default: 0 },
  },
  { timestamps: true }
);

export const User = mongoose.model('User', userSchema);

Passport Configuration

js
// config/passport.js
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import { Strategy as GitHubStrategy } from 'passport-github2';
import { User } from '../features/users/users.model.js';

// Shared handler — find or create a user for any OAuth provider
async function handleOAuthProfile(provider, profile, done) {
  try {
    const providerId  = profile.id;
    const email       = profile.emails?.[0]?.value;
    const name        = profile.displayName || profile.username;
    const avatar      = profile.photos?.[0]?.value;

    if (!email) {
      return done(new Error('No email returned from provider — ensure email scope is requested'));
    }

    // 1. Try to find user by this provider's ID
    let user = await User.findOne({
      providers: { $elemMatch: { name: provider, providerId } },
    });

    if (user) return done(null, user);

    // 2. Try to find user by email (link provider to existing account)
    user = await User.findOne({ email });

    if (user) {
      user.providers.push({ name: provider, providerId });
      if (!user.avatar) user.avatar = avatar;
      await user.save();
      return done(null, user);
    }

    // 3. Create new user
    user = await User.create({
      name,
      email,
      avatar,
      providers: [{ name: provider, providerId }],
    });

    done(null, user);
  } catch (err) {
    done(err);
  }
}

// ── Google Strategy ─────────────────────────────────────────────────────────
passport.use(
  new GoogleStrategy(
    {
      clientID:     process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
      callbackURL:  `${process.env.API_URL}/api/v1/auth/google/callback`,
      scope: ['profile', 'email'],
    },
    (accessToken, refreshToken, profile, done) =>
      handleOAuthProfile('google', profile, done)
  )
);

// ── GitHub Strategy ──────────────────────────────────────────────────────────
passport.use(
  new GitHubStrategy(
    {
      clientID:     process.env.GITHUB_CLIENT_ID,
      clientSecret: process.env.GITHUB_CLIENT_SECRET,
      callbackURL:  `${process.env.API_URL}/api/v1/auth/github/callback`,
      scope: ['user:email'],
    },
    (accessToken, refreshToken, profile, done) =>
      handleOAuthProfile('github', profile, done)
  )
);

export default passport;

Auth Router — OAuth Endpoints

js
// features/auth/auth.router.js
import express from 'express';
import passport from '../../config/passport.js';
import * as controller from './auth.controller.js';
import { validate } from '../../middleware/validate.js';
import { requireAuth } from '../../middleware/auth.js';
import { registerSchema, loginSchema } from './auth.schema.js';

const router = express.Router();

// ── Local auth ───────────────────────────────────────────────────────────────
router.post('/register', validate(registerSchema), controller.register);
router.post('/login',    validate(loginSchema),    controller.login);
router.post('/refresh',                            controller.refresh);
router.post('/logout',   requireAuth,              controller.logout);
router.get('/me',        requireAuth,              controller.me);

// ── Google OAuth ─────────────────────────────────────────────────────────────
// Step 1: Redirect user to Google
router.get(
  '/google',
  passport.authenticate('google', { session: false, scope: ['profile', 'email'] })
);

// Step 2: Handle Google callback
router.get(
  '/google/callback',
  passport.authenticate('google', { session: false, failureRedirect: '/login?error=google_failed' }),
  controller.oauthCallback
);

// ── GitHub OAuth ──────────────────────────────────────────────────────────────
router.get(
  '/github',
  passport.authenticate('github', { session: false, scope: ['user:email'] })
);

router.get(
  '/github/callback',
  passport.authenticate('github', { session: false, failureRedirect: '/login?error=github_failed' }),
  controller.oauthCallback
);

export default router;

OAuth Callback Controller

js
// features/auth/auth.controller.js
import * as authService from './auth.service.js';
import { signAccessToken, signRefreshToken } from '../../lib/tokens.js';

const REFRESH_COOKIE_OPTIONS = {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'lax',          // 'lax' (not 'strict') for OAuth callbacks — cross-site redirect
  maxAge: 7 * 24 * 60 * 60 * 1000,
  path: '/api/v1/auth/refresh',
};

// Shared handler for all OAuth providers
export function oauthCallback(req, res) {
  const user = req.user; // populated by Passport after successful auth

  const payload = {
    sub:     user._id.toString(),
    role:    user.role,
    version: user.tokenVersion,
  };

  const accessToken  = signAccessToken(payload);
  const refreshToken = signRefreshToken(payload);

  res.cookie('refreshToken', refreshToken, REFRESH_COOKIE_OPTIONS);

  // Redirect to frontend with access token in URL fragment
  // The frontend reads it from the URL and stores it in memory
  const frontendUrl = process.env.FRONTEND_URL;
  res.redirect(`${frontendUrl}/auth/callback#token=${accessToken}`);
}

// ... register, login, refresh, logout, me handlers from Module 18

Wiring Passport into app.js

js
// app.js
import passport from './config/passport.js';

// Initialise Passport — no session middleware needed (session: false on all routes)
app.use(passport.initialize());

That is all. No passport.session(), no serializeUser, no deserializeUser — because we are issuing JWTs, not session cookies.


Environment Variables

text
# .env
API_URL=http://localhost:3000
FRONTEND_URL=http://localhost:5173

# Google OAuth — from console.cloud.google.com
GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-google-client-secret

# GitHub OAuth — from github.com/settings/developers
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret

Setting Up Google OAuth Credentials

  1. Go to console.cloud.google.com
  2. Create a project → APIs & Services → Credentials
  3. Create OAuth 2.0 Client ID (Web application)
  4. Add authorised redirect URI: http://localhost:3000/api/v1/auth/google/callback
  5. Copy Client ID and Client Secret to .env

Setting Up GitHub OAuth Credentials

  1. Go to GitHub → Settings → Developer Settings → OAuth Apps → New OAuth App
  2. Set Authorization callback URL: http://localhost:3000/api/v1/auth/github/callback
  3. Copy Client ID and generate Client Secret

Frontend Callback Handler

The frontend receives the token from the URL fragment:

js
// Frontend: /auth/callback page
// URL looks like: http://localhost:5173/auth/callback#token=eyJhbGci...

const hash = window.location.hash.substring(1);   // remove '#'
const params = new URLSearchParams(hash);
const accessToken = params.get('token');

if (accessToken) {
  // Store in memory — NOT localStorage (XSS risk)
  window.__accessToken = accessToken;
  // Clear the token from the URL
  window.history.replaceState({}, '', '/dashboard');
  // Redirect to dashboard
  window.location.href = '/dashboard';
} else {
  window.location.href = '/login?error=auth_failed';
}

Account Linking for Logged-In Users

Allow users who already have a local account to connect social providers:

js
// features/auth/auth.router.js
// Link Google to an existing account (user must be logged in)
router.get(
  '/google/link',
  requireAuth,
  (req, res, next) => {
    // Store the current user ID in the OAuth state
    req.session = { linkUserId: req.user.sub };
    next();
  },
  passport.authenticate('google', { session: false, scope: ['profile', 'email'] })
);
js
// config/passport.js — modified handleOAuthProfile for linking
async function handleOAuthProfile(provider, profile, done, linkUserId) {
  try {
    // ...existing logic...

    // If linkUserId is provided, add provider to that user
    if (linkUserId) {
      const user = await User.findById(linkUserId);
      if (!user) return done(new Error('User not found'));

      const alreadyLinked = user.providers.some(
        p => p.name === provider && p.providerId === profile.id
      );

      if (!alreadyLinked) {
        user.providers.push({ name: provider, providerId: profile.id });
        await user.save();
      }

      return done(null, user);
    }

    // ...rest of normal flow...
  } catch (err) {
    done(err);
  }
}

Adding More Providers

The pattern is identical for every provider. Add Twitter/X:

bash
npm install passport-twitter
js
import { Strategy as TwitterStrategy } from 'passport-twitter';

passport.use(
  new TwitterStrategy(
    {
      consumerKey:    process.env.TWITTER_CLIENT_ID,
      consumerSecret: process.env.TWITTER_CLIENT_SECRET,
      callbackURL:    `${process.env.API_URL}/api/v1/auth/twitter/callback`,
      includeEmail:   true,
    },
    (token, tokenSecret, profile, done) =>
      handleOAuthProfile('twitter', profile, done)
  )
);
js
// Router — two lines per provider
router.get('/twitter', passport.authenticate('twitter', { session: false }));
router.get('/twitter/callback',
  passport.authenticate('twitter', { session: false, failureRedirect: '/login?error=twitter_failed' }),
  controller.oauthCallback
);

handleOAuthProfile is provider-agnostic — adding a new provider is two strategy lines and two route lines.


Security Notes

ConcernMitigation
CSRF on callbackPassport generates and validates the state parameter automatically
Token in URL fragmentFragment is never sent to the server and does not appear in server logs
Email spoofingOnly trust the email from the provider if the provider has verified it (profile.emails[0].verified)
Account takeover via emailAlways check verified before linking providers by email
Open redirectValidate FRONTEND_URL against an allowlist — never accept a redirect URL from the request
js
// Validate redirect target against allowlist
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '').split(',');

function safeRedirect(res, url) {
  const target = new URL(url);
  if (!ALLOWED_ORIGINS.includes(target.origin)) {
    return res.status(400).json({ error: 'Invalid redirect URL' });
  }
  res.redirect(url);
}

Node.js Full‑Stack Course — Module 21 of 32

Your API now supports social login with Google and GitHub. Continue to Module 22 to serve a React frontend directly from Express.


    Summary

    OAuth social login with Passport.js follows a consistent pattern for every provider:

    • The OAuth flow: redirect → user approves → callback with code → exchange for token → fetch profile → find/create user → issue JWT
    • Configure strategies in config/passport.js with a shared handleOAuthProfile handler that find-or-creates users by provider ID then by email
    • Always pass { session: false } to passport.authenticate() — you are issuing JWTs, not sessions
    • The oauthCallback controller signs access and refresh tokens, sets the refresh cookie, and redirects the frontend to /auth/callback#token=...
    • Use sameSite: 'lax' (not 'strict') for the refresh cookie on OAuth callbacks — OAuth involves a cross-site redirect
    • Store the access token in memory on the frontend — never in localStorage
    • Adding a new provider is two strategy lines + two route lines — handleOAuthProfile is reusable

    Continue to Module 22: Serving a React Frontend from Express →