Introduction to Social Login
Social login (also known as social sign-in or OAuth authentication) allows users to authenticate with your application using their existing accounts from social platforms like Google, Facebook, GitHub, and others. This approach offers several benefits for both users and developers:
Benefits of Social Login
- Improved User Experience: Users don't need to create and remember new credentials
- Higher Conversion Rates: Removes friction from the registration process
- Enhanced Security: Delegates authentication to trusted platforms with robust security measures
- Access to User Data: Potentially retrieve profile information, contacts, and other data (with permission)
- Reduced Development Effort: No need to build password recovery flows, email verification, etc.
In this lecture, we'll build a complete social login implementation using Node.js, Express, MongoDB, and Passport.js, with support for multiple providers and a unified user experience.
Project Setup and Configuration
Let's start by setting up our project structure and installing the necessary dependencies:
Project Structure
social-login-app/
├── config/
│ ├── passport.js # Passport strategies configuration
│ └── database.js # Database connection
├── controllers/
│ ├── authController.js # Authentication logic
│ └── userController.js # User-related logic
├── models/
│ └── User.js # User database model
├── routes/
│ ├── authRoutes.js # Authentication routes
│ └── userRoutes.js # User-related routes
├── views/
│ ├── layouts/
│ │ └── main.handlebars # Main layout template
│ ├── home.handlebars # Home page
│ ├── login.handlebars # Login page
│ └── profile.handlebars # User profile page
├── public/
│ ├── css/
│ ├── js/
│ └── images/
├── .env # Environment variables
├── app.js # Main application file
└── package.json # Project dependencies
Installing Dependencies
// Initialize npm project
npm init -y
// Install core dependencies
npm install express express-handlebars express-session connect-mongo mongoose dotenv
// Install passport and strategies
npm install passport passport-google-oauth20 passport-facebook passport-github2 passport-twitter
// Install utilities
npm install connect-flash bcryptjs helmet cors morgan
Environment Configuration
Create a .env file in the root directory to store your sensitive configuration:
# .env file
NODE_ENV=development
PORT=3000
MONGODB_URI=mongodb://localhost:27017/social-login
SESSION_SECRET=your_super_secure_session_secret
# Google OAuth
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
# Facebook OAuth
FACEBOOK_APP_ID=your_facebook_app_id
FACEBOOK_APP_SECRET=your_facebook_app_secret
# GitHub OAuth
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
# Twitter OAuth
TWITTER_CONSUMER_KEY=your_twitter_consumer_key
TWITTER_CONSUMER_SECRET=your_twitter_consumer_secret
Obtaining OAuth Credentials
You'll need to register your application with each provider to get the required credentials:
- Google: Register in the Google Developer Console, create OAuth credentials
- Facebook: Register in the Facebook Developer Portal, create an app
- GitHub: Register in GitHub Developer Settings, create OAuth App
- Twitter: Register in the Twitter Developer Portal, create an app
For each provider, you'll need to specify the appropriate callback URLs (e.g., http://localhost:3000/auth/google/callback for development).
Database Models and Configuration
Let's set up our MongoDB connection and define the User model that will store authentication information from various providers:
Database Connection
// config/database.js
const mongoose = require('mongoose');
const connectDB = async () => {
try {
const conn = await mongoose.connect(process.env.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
useFindAndModify: false,
useCreateIndex: true
});
console.log(`MongoDB Connected: ${conn.connection.host}`);
} catch (err) {
console.error(`Error connecting to MongoDB: ${err.message}`);
process.exit(1);
}
};
module.exports = connectDB;
User Model
Create a flexible User model that can store authentication information from different providers:
// models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const UserSchema = new mongoose.Schema({
// Common user fields
name: {
type: String,
required: true
},
email: {
type: String,
required: true,
unique: true,
lowercase: true,
trim: true
},
password: {
type: String,
// Not required because social login users won't have a password
},
avatar: {
type: String
},
role: {
type: String,
enum: ['user', 'admin'],
default: 'user'
},
// Social provider fields
googleId: String,
facebookId: String,
githubId: String,
twitterId: String,
// Provider data storage
providerData: {
type: Map,
of: mongoose.Schema.Types.Mixed
},
// Account status
emailVerified: {
type: Boolean,
default: false
},
active: {
type: Boolean,
default: true
},
// Timestamps
createdAt: {
type: Date,
default: Date.now
},
updatedAt: {
type: Date,
default: Date.now
}
});
// Pre-save middleware to hash password
UserSchema.pre('save', async function(next) {
// Only hash the password if it has been modified (or is new)
if (!this.isModified('password') || !this.password) {
return next();
}
try {
// Generate a salt
const salt = await bcrypt.genSalt(10);
// Hash the password with the salt
this.password = await bcrypt.hash(this.password, salt);
this.updatedAt = Date.now();
next();
} catch (err) {
next(err);
}
});
// Method to compare password
UserSchema.methods.comparePassword = async function(candidatePassword) {
try {
if (!this.password) return false;
return await bcrypt.compare(candidatePassword, this.password);
} catch (err) {
throw err;
}
};
// Method to check if user has a specific provider
UserSchema.methods.hasProvider = function(provider) {
return Boolean(this[`${provider}Id`]);
};
// Method to list all connected providers
UserSchema.methods.getProviders = function() {
const providers = [];
if (this.googleId) providers.push('google');
if (this.facebookId) providers.push('facebook');
if (this.githubId) providers.push('github');
if (this.twitterId) providers.push('twitter');
if (this.password) providers.push('local');
return providers;
};
const User = mongoose.model('User', UserSchema);
module.exports = User;
Model Design Considerations
This model design addresses several important aspects of social login:
- Provider IDs: Separate fields for each provider's unique identifier
- Dynamic Provider Data: Using a Map to store provider-specific data
- Password Support: Optional password field for hybrid authentication
- Helper Methods: Utility functions to check providers and manage authentication
This flexible approach allows users to connect multiple social accounts to a single user profile.
Passport.js Configuration
Now, let's configure Passport.js with strategies for multiple social providers:
// config/passport.js
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const FacebookStrategy = require('passport-facebook').Strategy;
const GitHubStrategy = require('passport-github2').Strategy;
const TwitterStrategy = require('passport-twitter').Strategy;
const LocalStrategy = require('passport-local').Strategy;
const User = require('../models/User');
// Serialize user into the session
passport.serializeUser((user, done) => {
done(null, user.id);
});
// Deserialize user from the session
passport.deserializeUser(async (id, done) => {
try {
const user = await User.findById(id);
done(null, user);
} catch (err) {
done(err);
}
});
// Common OAuth callback function
async function oauthCallback(accessToken, refreshToken, profile, provider, done) {
try {
// Extract profile information based on provider
const profileData = extractProfileData(profile, provider);
// First, try to find by provider ID
const providerField = `${provider}Id`;
let user = await User.findOne({ [providerField]: profile.id });
// If user exists, update their profile data
if (user) {
// Update user with latest provider data
user.providerData.set(provider, {
accessToken,
refreshToken,
profile: profile._json
});
user.updatedAt = Date.now();
await user.save();
return done(null, user);
}
// If no user with this provider ID, check by email
if (profileData.email) {
user = await User.findOne({ email: profileData.email });
if (user) {
// Connect this provider to existing account
user[providerField] = profile.id;
user.providerData.set(provider, {
accessToken,
refreshToken,
profile: profile._json
});
user.updatedAt = Date.now();
// If email wasn't verified before but provider verifies it
if (!user.emailVerified && profileData.emailVerified) {
user.emailVerified = true;
}
await user.save();
return done(null, user);
}
}
// Create new user if not found
const newUser = new User({
name: profileData.name,
email: profileData.email || `${profile.id}@${provider}.user`,
avatar: profileData.avatar,
[providerField]: profile.id,
emailVerified: profileData.emailVerified,
providerData: new Map([[
provider, {
accessToken,
refreshToken,
profile: profile._json
}
]])
});
await newUser.save();
return done(null, newUser);
} catch (err) {
return done(err);
}
}
// Helper to extract profile data from different providers
function extractProfileData(profile, provider) {
switch (provider) {
case 'google':
return {
name: profile.displayName,
email: profile.emails?.[0]?.value,
avatar: profile.photos?.[0]?.value,
emailVerified: true // Google verifies emails
};
case 'facebook':
return {
name: profile.displayName,
email: profile.emails?.[0]?.value,
avatar: profile.photos?.[0]?.value,
emailVerified: true // Facebook verifies emails
};
case 'github':
return {
name: profile.displayName || profile.username,
email: profile.emails?.[0]?.value,
avatar: profile.photos?.[0]?.value,
emailVerified: Boolean(profile.emails?.[0]?.verified)
};
case 'twitter':
return {
name: profile.displayName,
// Twitter doesn't provide email by default
email: profile.emails?.[0]?.value,
avatar: profile.photos?.[0]?.value.replace('_normal', ''),
emailVerified: false // Twitter doesn't verify emails by default
};
default:
return {
name: profile.displayName,
email: profile.emails?.[0]?.value,
avatar: profile.photos?.[0]?.value,
emailVerified: false
};
}
}
// Configure Google Strategy
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: '/auth/google/callback',
scope: ['profile', 'email']
}, (accessToken, refreshToken, profile, done) => {
oauthCallback(accessToken, refreshToken, profile, 'google', done);
}));
// Configure Facebook Strategy
passport.use(new FacebookStrategy({
clientID: process.env.FACEBOOK_APP_ID,
clientSecret: process.env.FACEBOOK_APP_SECRET,
callbackURL: '/auth/facebook/callback',
profileFields: ['id', 'displayName', 'photos', 'email']
}, (accessToken, refreshToken, profile, done) => {
oauthCallback(accessToken, refreshToken, profile, 'facebook', done);
}));
// Configure GitHub Strategy
passport.use(new GitHubStrategy({
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: '/auth/github/callback',
scope: ['user:email']
}, (accessToken, refreshToken, profile, done) => {
oauthCallback(accessToken, refreshToken, profile, 'github', done);
}));
// Configure Twitter Strategy
passport.use(new TwitterStrategy({
consumerKey: process.env.TWITTER_CONSUMER_KEY,
consumerSecret: process.env.TWITTER_CONSUMER_SECRET,
callbackURL: '/auth/twitter/callback',
includeEmail: true
}, (token, tokenSecret, profile, done) => {
// For Twitter, token secret is used instead of refresh token
oauthCallback(token, tokenSecret, profile, 'twitter', done);
}));
// Configure Local Strategy
passport.use(new LocalStrategy(
{ usernameField: 'email' },
async (email, password, done) => {
try {
// Find user by email
const user = await User.findOne({ email: email.toLowerCase() });
// User not found
if (!user) {
return done(null, false, { message: 'Invalid email or password' });
}
// User found but no password (social auth only)
if (!user.password) {
return done(null, false, { message: 'This account does not have a password. Please log in with a social provider.' });
}
// Check password
const isMatch = await user.comparePassword(password);
if (!isMatch) {
return done(null, false, { message: 'Invalid email or password' });
}
return done(null, user);
} catch (err) {
return done(err);
}
}
));
module.exports = passport;
Key Features of This Configuration
- Unified OAuth Callback: A common function handles all social providers
- Profile Data Extraction: Provider-specific logic to normalize user data
- Account Linking: Automatically links accounts with matching emails
- Provider Data Storage: Stores access tokens and profile data for future API calls
- Email Verification: Tracks which providers verify email addresses
- Local Strategy: Supports traditional email/password authentication alongside social login
This approach makes it easy to add more providers in the future while maintaining consistent behavior.
Authentication Routes
Now, let's set up the routes for various authentication methods:
// routes/authRoutes.js
const express = require('express');
const passport = require('passport');
const router = express.Router();
const User = require('../models/User');
// Helper function to handle auth redirect
function handleAuthRedirect(req, res) {
// Redirect to previous page or default to profile
const redirectUrl = req.session.returnTo || '/profile';
delete req.session.returnTo;
res.redirect(redirectUrl);
}
// Google OAuth Routes
router.get('/google', passport.authenticate('google', { scope: ['profile', 'email'] }));
router.get('/google/callback',
passport.authenticate('google', { failureRedirect: '/login' }),
handleAuthRedirect
);
// Facebook OAuth Routes
router.get('/facebook', passport.authenticate('facebook', { scope: ['email'] }));
router.get('/facebook/callback',
passport.authenticate('facebook', { failureRedirect: '/login' }),
handleAuthRedirect
);
// GitHub OAuth Routes
router.get('/github', passport.authenticate('github', { scope: ['user:email'] }));
router.get('/github/callback',
passport.authenticate('github', { failureRedirect: '/login' }),
handleAuthRedirect
);
// Twitter OAuth Routes
router.get('/twitter', passport.authenticate('twitter'));
router.get('/twitter/callback',
passport.authenticate('twitter', { failureRedirect: '/login' }),
handleAuthRedirect
);
// Local Authentication Routes
router.post('/login', (req, res, next) => {
passport.authenticate('local', (err, user, info) => {
if (err) return next(err);
if (!user) {
req.flash('error', info.message);
return res.redirect('/login');
}
req.login(user, (err) => {
if (err) return next(err);
return handleAuthRedirect(req, res);
});
})(req, res, next);
});
// Registration route
router.post('/register', async (req, res, next) => {
try {
const { name, email, password, password2 } = req.body;
// Basic validation
const errors = [];
if (!name || !email || !password || !password2) {
errors.push({ msg: 'Please fill in all fields' });
}
if (password !== password2) {
errors.push({ msg: 'Passwords do not match' });
}
if (password.length < 6) {
errors.push({ msg: 'Password should be at least 6 characters' });
}
if (errors.length > 0) {
return res.render('register', {
errors,
name,
email
});
}
// Check if user already exists
const existingUser = await User.findOne({ email: email.toLowerCase() });
if (existingUser) {
if (existingUser.password) {
// User exists with password
errors.push({ msg: 'Email is already registered' });
return res.render('register', {
errors,
name,
email
});
} else {
// User exists but only with social login, set password
existingUser.name = name;
existingUser.password = password;
await existingUser.save();
req.flash('success_msg', 'Password set successfully, you can now log in with email');
return res.redirect('/login');
}
}
// Create new user
const newUser = new User({
name,
email: email.toLowerCase(),
password
});
await newUser.save();
req.flash('success_msg', 'You are now registered and can log in');
res.redirect('/login');
} catch (err) {
next(err);
}
});
// Logout route
router.get('/logout', (req, res) => {
req.logout();
req.flash('success_msg', 'You are logged out');
res.redirect('/');
});
// Account linking routes
router.get('/connect/google',
passport.authorize('google', { scope: ['profile', 'email'] })
);
router.get('/connect/facebook',
passport.authorize('facebook', { scope: ['email'] })
);
router.get('/connect/github',
passport.authorize('github', { scope: ['user:email'] })
);
router.get('/connect/twitter',
passport.authorize('twitter')
);
// Account unlinking routes
router.get('/unlink/:provider', async (req, res, next) => {
try {
if (!req.user) {
req.flash('error_msg', 'You must be logged in');
return res.redirect('/login');
}
const provider = req.params.provider;
const validProviders = ['google', 'facebook', 'github', 'twitter', 'local'];
if (!validProviders.includes(provider)) {
req.flash('error_msg', 'Invalid provider');
return res.redirect('/profile');
}
// Get user and connected providers
const user = await User.findById(req.user.id);
const connectedProviders = user.getProviders();
// Don't allow unlinking the only provider
if (connectedProviders.length <= 1) {
req.flash('error_msg', 'Cannot unlink your only authentication method');
return res.redirect('/profile');
}
// Unlink provider
if (provider === 'local') {
user.password = undefined;
} else {
user[`${provider}Id`] = undefined;
user.providerData.delete(provider);
}
await user.save();
req.flash('success_msg', `${provider.charAt(0).toUpperCase() + provider.slice(1)} successfully unlinked`);
res.redirect('/profile');
} catch (err) {
next(err);
}
});
module.exports = router;
Route Design Patterns
These routes implement several important patterns:
- Return URL Handling: Redirects users back to their original destination after authentication
- Flash Messages: Provides feedback to users about authentication actions
- Account Linking/Unlinking: Allows users to connect and disconnect social accounts
- Hybrid Account Management: Handles cases where users have both social and local authentication
- Error Handling: Provides user-friendly error messages for authentication issues
This comprehensive approach gives users flexibility in how they authenticate while maintaining security.
Application Setup
Let's set up the main application file to tie everything together:
// app.js
const express = require('express');
const exphbs = require('express-handlebars');
const session = require('express-session');
const MongoStore = require('connect-mongo')(session);
const mongoose = require('mongoose');
const passport = require('./config/passport');
const flash = require('connect-flash');
const helmet = require('helmet');
const cors = require('cors');
const morgan = require('morgan');
const path = require('path');
const connectDB = require('./config/database');
require('dotenv').config();
// Import routes
const authRoutes = require('./routes/authRoutes');
const userRoutes = require('./routes/userRoutes');
// Initialize app
const app = express();
// Connect to database
connectDB();
// Middleware
if (process.env.NODE_ENV === 'development') {
app.use(morgan('dev'));
}
// Body parser
app.use(express.urlencoded({ extended: false }));
app.use(express.json());
// Security headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", 'cdnjs.cloudflare.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'fonts.googleapis.com', 'cdnjs.cloudflare.com'],
fontSrc: ["'self'", 'fonts.gstatic.com', 'cdnjs.cloudflare.com'],
imgSrc: ["'self'", 'data:', '*.googleusercontent.com', '*.githubusercontent.com', '*.fbcdn.net', 'twimg.com'],
},
},
}));
// CORS
app.use(cors());
// Static folder
app.use(express.static(path.join(__dirname, 'public')));
// Handlebars
app.engine('handlebars', exphbs({
defaultLayout: 'main',
helpers: {
// Custom helpers
ifEquals: function(arg1, arg2, options) {
return (arg1 === arg2) ? options.fn(this) : options.inverse(this);
},
includes: function(array, value, options) {
return array.includes(value) ? options.fn(this) : options.inverse(this);
}
}
}));
app.set('view engine', 'handlebars');
// Sessions
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
store: new MongoStore({ mongooseConnection: mongoose.connection }),
cookie: {
maxAge: 1000 * 60 * 60 * 24, // 1 day
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax'
}
}));
// Passport middleware
app.use(passport.initialize());
app.use(passport.session());
// Flash messages
app.use(flash());
// Global variables
app.use((req, res, next) => {
res.locals.success_msg = req.flash('success_msg');
res.locals.error_msg = req.flash('error_msg');
res.locals.error = req.flash('error');
res.locals.user = req.user || null;
next();
});
// Store return URL for redirects
app.use((req, res, next) => {
if (!req.isAuthenticated() && req.method === 'GET' && !req.originalUrl.startsWith('/auth') && req.originalUrl !== '/login' && req.originalUrl !== '/register') {
req.session.returnTo = req.originalUrl;
}
next();
});
// Routes
app.get('/', (req, res) => {
res.render('home');
});
app.get('/login', (req, res) => {
if (req.isAuthenticated()) {
return res.redirect('/profile');
}
res.render('login');
});
app.get('/register', (req, res) => {
if (req.isAuthenticated()) {
return res.redirect('/profile');
}
res.render('register');
});
// Authentication middleware
function ensureAuthenticated(req, res, next) {
if (req.isAuthenticated()) {
return next();
}
req.flash('error_msg', 'Please log in to view this resource');
res.redirect('/login');
}
// Protected route
app.get('/profile', ensureAuthenticated, (req, res) => {
res.render('profile', {
user: req.user,
providers: req.user.getProviders()
});
});
// Mount route files
app.use('/auth', authRoutes);
app.use('/user', ensureAuthenticated, userRoutes);
// 404 handler
app.use((req, res) => {
res.status(404).render('404');
});
// Error handler
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).render('error', {
message: process.env.NODE_ENV === 'production' ? 'Something went wrong' : err.message
});
});
// Start server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running in ${process.env.NODE_ENV} mode on port ${PORT}`);
});
Security Considerations
This implementation includes several important security measures:
- Helmet.js: Sets secure HTTP headers including Content Security Policy
- Secure Cookies: HttpOnly, Secure, and SameSite attributes protect session cookies
- Session Store: Using MongoDB instead of the default MemoryStore for production readiness
- CSRF Protection: Through SameSite cookies and proper session management
- Input Validation: Validates user input for registration and profile updates
- Error Handling: Production-appropriate error messages that don't leak implementation details
These measures help protect user data and prevent common security vulnerabilities.
View Templates
Let's create the Handlebars templates for the user interface. First, the main layout:
// views/layouts/main.handlebars
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Social Login Demo</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/4.6.0/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-primary mb-4">
<div class="container">
<a class="navbar-brand" href="/">Social Login Demo</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ml-auto">
<li class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
{{#if user}}
<li class="nav-item">
<a class="nav-link" href="/profile">Profile</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/auth/logout">Logout</a>
</li>
{{else}}
<li class="nav-item">
<a class="nav-link" href="/login">Login</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/register">Register</a>
</li>
{{/if}}
</ul>
</div>
</div>
</nav>
<div class="container">
{{#if success_msg}}
<div class="alert alert-success alert-dismissible fade show">
{{success_msg}}
<button type="button" class="close" data-dismiss="alert"><span>×</span></button>
</div>
{{/if}}
{{#if error_msg}}
<div class="alert alert-danger alert-dismissible fade show">
{{error_msg}}
<button type="button" class="close" data-dismiss="alert"><span>×</span></button>
</div>
{{/if}}
{{#if error}}
<div class="alert alert-danger alert-dismissible fade show">
{{error}}
<button type="button" class="close" data-dismiss="alert"><span>×</span></button>
</div>
{{/if}}
{{{body}}}
</div>
<footer class="bg-light mt-5 py-3">
<div class="container text-center">
<p>© 2025 Social Login Demo</p>
</div>
</footer>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/4.6.0/js/bootstrap.bundle.min.js"></script>
<script src="/js/main.js"></script>
</body>
</html>
Login page with social login buttons:
// views/login.handlebars
<div class="row mt-5">
<div class="col-md-6 m-auto">
<div class="card card-body">
<h1 class="text-center mb-3">Login</h1>
<form action="/auth/login" method="POST">
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" class="form-control" placeholder="Enter Email" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" class="form-control" placeholder="Enter Password" required>
</div>
<button type="submit" class="btn btn-primary btn-block">Login</button>
</form>
<hr>
<p class="text-center">Or login with:</p>
<div class="social-auth-links text-center mb-3">
<a href="/auth/google" class="btn btn-block btn-danger">
<i class="fab fa-google mr-2"></i> Google
</a>
<a href="/auth/facebook" class="btn btn-block btn-primary">
<i class="fab fa-facebook-f mr-2"></i> Facebook
</a>
<a href="/auth/github" class="btn btn-block btn-dark">
<i class="fab fa-github mr-2"></i> GitHub
</a>
<a href="/auth/twitter" class="btn btn-block btn-info">
<i class="fab fa-twitter mr-2"></i> Twitter
</a>
</div>
<p class="lead mt-4">No Account? <a href="/register">Register</a></p>
</div>
</div>
</div>
User profile page with connected accounts:
// views/profile.handlebars
<div class="row">
<div class="col-md-4">
<div class="card">
<div class="card-body text-center">
{{#if user.avatar}}
<img src="{{user.avatar}}" alt="{{user.name}}" class="rounded-circle img-fluid mb-3" style="max-width: 150px;">
{{else}}
<img src="/images/default-avatar.png" alt="{{user.name}}" class="rounded-circle img-fluid mb-3" style="max-width: 150px;">
{{/if}}
<h3>{{user.name}}</h3>
<p>{{user.email}}</p>
<hr>
<h5>Connected Accounts</h5>
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center">
<span><i class="fas fa-envelope mr-2"></i> Email/Password</span>
{{#if (includes providers 'local')}}
<span class="badge badge-success">Connected</span>
{{#if (includes providers 'google')}}
<a href="/auth/unlink/local" class="btn btn-sm btn-outline-danger">Unlink</a>
{{else if (includes providers 'facebook')}}
<a href="/auth/unlink/local" class="btn btn-sm btn-outline-danger">Unlink</a>
{{else if (includes providers 'github')}}
<a href="/auth/unlink/local" class="btn btn-sm btn-outline-danger">Unlink</a>
{{else if (includes providers 'twitter')}}
<a href="/auth/unlink/local" class="btn btn-sm btn-outline-danger">Unlink</a>
{{/if}}
{{else}}
<span class="badge badge-secondary">Not Connected</span>
<a href="#" data-toggle="modal" data-target="#setPasswordModal" class="btn btn-sm btn-outline-primary">Connect</a>
{{/if}}
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
<span><i class="fab fa-google mr-2"></i> Google</span>
{{#if (includes providers 'google')}}
<span class="badge badge-success">Connected</span>
{{#if (includes providers 'local')}}
<a href="/auth/unlink/google" class="btn btn-sm btn-outline-danger">Unlink</a>
{{else if (includes providers 'facebook')}}
<a href="/auth/unlink/google" class="btn btn-sm btn-outline-danger">Unlink</a>
{{else if (includes providers 'github')}}
<a href="/auth/unlink/google" class="btn btn-sm btn-outline-danger">Unlink</a>
{{else if (includes providers 'twitter')}}
<a href="/auth/unlink/google" class="btn btn-sm btn-outline-danger">Unlink</a>
{{/if}}
{{else}}
<span class="badge badge-secondary">Not Connected</span>
<a href="/auth/connect/google" class="btn btn-sm btn-outline-primary">Connect</a>
{{/if}}
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
<span><i class="fab fa-facebook-f mr-2"></i> Facebook</span>
{{#if (includes providers 'facebook')}}
<span class="badge badge-success">Connected</span>
{{#if (includes providers 'local')}}
<a href="/auth/unlink/facebook" class="btn btn-sm btn-outline-danger">Unlink</a>
{{else if (includes providers 'google')}}
<a href="/auth/unlink/facebook" class="btn btn-sm btn-outline-danger">Unlink</a>
{{else if (includes providers 'github')}}
<a href="/auth/unlink/facebook" class="btn btn-sm btn-outline-danger">Unlink</a>
{{else if (includes providers 'twitter')}}
<a href="/auth/unlink/facebook" class="btn btn-sm btn-outline-danger">Unlink</a>
{{/if}}
{{else}}
<span class="badge badge-secondary">Not Connected</span>
<a href="/auth/connect/facebook" class="btn btn-sm btn-outline-primary">Connect</a>
{{/if}}
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
<span><i class="fab fa-github mr-2"></i> GitHub</span>
{{#if (includes providers 'github')}}
<span class="badge badge-success">Connected</span>
{{#if (includes providers 'local')}}
<a href="/auth/unlink/github" class="btn btn-sm btn-outline-danger">Unlink</a>
{{else if (includes providers 'google')}}
<a href="/auth/unlink/github" class="btn btn-sm btn-outline-danger">Unlink</a>
{{else if (includes providers 'facebook')}}
<a href="/auth/unlink/github" class="btn btn-sm btn-outline-danger">Unlink</a>
{{else if (includes providers 'twitter')}}
<a href="/auth/unlink/github" class="btn btn-sm btn-outline-danger">Unlink</a>
{{/if}}
{{else}}
<span class="badge badge-secondary">Not Connected</span>
<a href="/auth/connect/github" class="btn btn-sm btn-outline-primary">Connect</a>
{{/if}}
</li>
<li class="list-group-item d-flex justify-content-between align-items-center">
<span><i class="fab fa-twitter mr-2"></i> Twitter</span>
{{#if (includes providers 'twitter')}}
<span class="badge badge-success">Connected</span>
{{#if (includes providers 'local')}}
<a href="/auth/unlink/twitter" class="btn btn-sm btn-outline-danger">Unlink</a>
{{else if (includes providers 'google')}}
<a href="/auth/unlink/twitter" class="btn btn-sm btn-outline-danger">Unlink</a>
{{else if (includes providers 'facebook')}}
<a href="/auth/unlink/twitter" class="btn btn-sm btn-outline-danger">Unlink</a>
{{else if (includes providers 'github')}}
<a href="/auth/unlink/twitter" class="btn btn-sm btn-outline-danger">Unlink</a>
{{/if}}
{{else}}
<span class="badge badge-secondary">Not Connected</span>
<a href="/auth/connect/twitter" class="btn btn-sm btn-outline-primary">Connect</a>
{{/if}}
</li>
</ul>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h4>Profile Information</h4>
</div>
<div class="card-body">
<form action="/user/update-profile" method="POST">
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" name="name" class="form-control" value="{{user.name}}" required>
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" class="form-control" value="{{user.email}}" readonly>
<small class="form-text text-muted">Email cannot be changed.</small>
</div>
<button type="submit" class="btn btn-primary">Update Profile</button>
</form>
</div>
</div>
{{#unless (includes providers 'local')}}
<div class="card mt-4">
<div class="card-header">
<h4>Add Password</h4>
</div>
<div class="card-body">
<p>Setting a password allows you to log in using your email address.</p>
<button class="btn btn-primary" data-toggle="modal" data-target="#setPasswordModal">
Set Password
</button>
</div>
</div>
{{/unless}}
{{#if (includes providers 'local')}}
<div class="card mt-4">
<div class="card-header">
<h4>Change Password</h4>
</div>
<div class="card-body">
<form action="/user/change-password" method="POST">
<div class="form-group">
<label for="currentPassword">Current Password</label>
<input type="password" id="currentPassword" name="currentPassword" class="form-control" required>
</div>
<div class="form-group">
<label for="newPassword">New Password</label>
<input type="password" id="newPassword" name="newPassword" class="form-control" required>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm New Password</label>
<input type="password" id="confirmPassword" name="confirmPassword" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary">Change Password</button>
</form>
</div>
</div>
{{/if}}
</div>
</div>
<!-- Set Password Modal -->
<div class="modal fade" id="setPasswordModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Set Password</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<form action="/user/set-password" method="POST">
<div class="modal-body">
<div class="form-group">
<label for="newPassword">New Password</label>
<input type="password" id="newPassword" name="newPassword" class="form-control" required>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<input type="password" id="confirmPassword" name="confirmPassword" class="form-control" required>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save Password</button>
</div>
</form>
</div>
</div>
</div>
UI Design Considerations
These templates implement several important UI patterns:
- Clear Authentication Options: Multiple login methods clearly presented
- Seamless Account Linking: Easy connection of multiple providers
- Safe Unlinking: Prevents removing the only authentication method
- Password Management: Adding/changing password for social-only accounts
- Feedback Messages: Clear status and error messages for user actions
- Responsive Design: Works well on all device sizes
This user-friendly approach makes authentication easy while maintaining security.
User Routes
Let's implement the user routes for profile management:
// routes/userRoutes.js
const express = require('express');
const router = express.Router();
const User = require('../models/User');
const bcrypt = require('bcryptjs');
// Update profile
router.post('/update-profile', async (req, res, next) => {
try {
const { name } = req.body;
// Simple validation
if (!name) {
req.flash('error_msg', 'Name is required');
return res.redirect('/profile');
}
// Find and update user
const user = await User.findById(req.user.id);
user.name = name;
user.updatedAt = Date.now();
await user.save();
req.flash('success_msg', 'Profile updated successfully');
res.redirect('/profile');
} catch (err) {
next(err);
}
});
// Set password for social-only accounts
router.post('/set-password', async (req, res, next) => {
try {
const { newPassword, confirmPassword } = req.body;
// Validation
if (newPassword !== confirmPassword) {
req.flash('error_msg', 'Passwords do not match');
return res.redirect('/profile');
}
if (newPassword.length < 6) {
req.flash('error_msg', 'Password must be at least 6 characters');
return res.redirect('/profile');
}
// Find and update user
const user = await User.findById(req.user.id);
user.password = newPassword; // Will be hashed by pre-save hook
user.updatedAt = Date.now();
await user.save();
req.flash('success_msg', 'Password set successfully');
res.redirect('/profile');
} catch (err) {
next(err);
}
});
// Change existing password
router.post('/change-password', async (req, res, next) => {
try {
const { currentPassword, newPassword, confirmPassword } = req.body;
// Validation
if (newPassword !== confirmPassword) {
req.flash('error_msg', 'New passwords do not match');
return res.redirect('/profile');
}
if (newPassword.length < 6) {
req.flash('error_msg', 'Password must be at least 6 characters');
return res.redirect('/profile');
}
// Find user
const user = await User.findById(req.user.id);
// Verify current password
const isMatch = await user.comparePassword(currentPassword);
if (!isMatch) {
req.flash('error_msg', 'Current password is incorrect');
return res.redirect('/profile');
}
// Update password
user.password = newPassword; // Will be hashed by pre-save hook
user.updatedAt = Date.now();
await user.save();
req.flash('success_msg', 'Password changed successfully');
res.redirect('/profile');
} catch (err) {
next(err);
}
});
module.exports = router;
User Management Features
These routes implement key user management functionality:
- Profile Updates: Allows users to update basic profile information
- Password Setting: Enables social-only users to add password authentication
- Password Changes: Secure password changing with verification
- Input Validation: Validates all user inputs before processing
- Security Checks: Verifies current password before changes
These features enhance security while providing users with control over their authentication methods.
Testing and Debugging
Testing a social login implementation can be challenging. Here are some strategies:
Testing Strategy
- Development Provider Setup: Create separate OAuth provider credentials for testing
- Mock OAuth Provider: For automated testing, mock the OAuth provider responses
- Session Inspection: Use tools to examine session data during authentication
- Network Monitoring: Watch redirects and API calls during the OAuth flow
- Error Logging: Implement detailed error logging for authentication issues
- User Flow Testing: Test various authentication paths and edge cases
Common OAuth Debugging Issues
| Issue | Possible Causes | Solutions |
|---|---|---|
| Redirect URI Mismatch | URI in code doesn't match registered URI | Ensure exact match, including protocol, path, and case sensitivity |
| Missing Scopes | Insufficient scopes requested | Add required scopes for needed user data (email, profile, etc.) |
| Unauthorized Redirect URI | Callback URL not allowed by provider | Add the callback URL to the provider's allowed redirect URIs list |
| Session Issues | Session not properly configured | Check cookie settings, session store connection |
| Missing Profile Data | Provider not returning expected fields | Check scopes, provider documentation for required permissions |
| CORS Issues | Cross-origin restrictions | Ensure proper CORS configuration, especially for SPA implementations |
Testing with Mock OAuth
// Mock OAuth provider for testing
// test/mockOAuth.js
const express = require('express');
const crypto = require('crypto');
// Create a mock OAuth server
const mockOAuthServer = express();
// Store mock users and authorization codes
const mockUsers = {
'mock-google-user': {
id: '123456789',
displayName: 'Mock Google User',
emails: [{ value: 'mockgoogle@example.com' }],
photos: [{ value: 'https://example.com/mockgoogle.jpg' }],
provider: 'google'
},
'mock-facebook-user': {
id: '987654321',
displayName: 'Mock Facebook User',
emails: [{ value: 'mockfacebook@example.com' }],
photos: [{ value: 'https://example.com/mockfacebook.jpg' }],
provider: 'facebook'
}
};
const authCodes = {};
const accessTokens = {};
// Authorization endpoint
mockOAuthServer.get('/auth', (req, res) => {
const { client_id, redirect_uri, state, response_type, scope } = req.query;
// Validate parameters
if (!client_id || !redirect_uri || !state || response_type !== 'code') {
return res.status(400).send('Invalid request parameters');
}
// Create authorization code
const code = crypto.randomBytes(16).toString('hex');
// Store code with associated user (mock selection)
const userId = req.query.mock_user || 'mock-google-user';
authCodes[code] = {
client_id,
redirect_uri,
userId
};
// Redirect back with code
const redirectUrl = new URL(redirect_uri);
redirectUrl.searchParams.append('code', code);
redirectUrl.searchParams.append('state', state);
res.redirect(redirectUrl.toString());
});
// Token endpoint
mockOAuthServer.post('/token', express.urlencoded({ extended: true }), (req, res) => {
const { code, client_id, client_secret, redirect_uri, grant_type } = req.body;
// Validate parameters
if (!code || !client_id || !redirect_uri || grant_type !== 'authorization_code') {
return res.status(400).json({ error: 'invalid_request' });
}
// Check if code exists and matches
const codeData = authCodes[code];
if (!codeData || codeData.client_id !== client_id || codeData.redirect_uri !== redirect_uri) {
return res.status(400).json({ error: 'invalid_grant' });
}
// Generate tokens
const accessToken = crypto.randomBytes(32).toString('hex');
const refreshToken = crypto.randomBytes(32).toString('hex');
// Store tokens
accessTokens[accessToken] = {
userId: codeData.userId,
client_id
};
// Delete used code
delete authCodes[code];
// Return tokens
res.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: refreshToken
});
});
// User info endpoint
mockOAuthServer.get('/userinfo', (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'invalid_token' });
}
const token = authHeader.substring(7);
const tokenData = accessTokens[token];
if (!tokenData) {
return res.status(401).json({ error: 'invalid_token' });
}
const user = mockUsers[tokenData.userId];
if (!user) {
return res.status(404).json({ error: 'user_not_found' });
}
res.json({
id: user.id,
name: user.displayName,
email: user.emails[0].value,
picture: user.photos[0].value
});
});
// Start server
const PORT = 3001;
mockOAuthServer.listen(PORT, () => {
console.log(`Mock OAuth server running on port ${PORT}`);
});
// Export server for testing
module.exports = mockOAuthServer;
Automated Testing
// test/auth.test.js
const request = require('supertest');
const mongoose = require('mongoose');
const mockOAuthServer = require('./mockOAuth');
const app = require('../app');
const User = require('../models/User');
// Original passport config
const originalPassport = require('../config/passport');
// Mock passport strategies for testing
jest.mock('passport', () => {
const originalPassport = jest.requireActual('passport');
// Create a mocked version that overrides the authenticate method
const passport = {
...originalPassport,
authenticate: jest.fn().mockImplementation((strategy, options, callback) => {
// Return middleware that simulates authentication
return (req, res, next) => {
// Simulate successful authentication
if (strategy === 'google' && req.path === '/auth/google/callback') {
req.user = {
id: '123456789',
name: 'Mock Google User',
email: 'mockgoogle@example.com',
googleId: '123456789'
};
if (callback) {
return callback(null, req.user, null)(req, res, next);
}
req.login(req.user, (err) => {
if (err) return next(err);
return next();
});
} else {
next();
}
};
}),
initialize: originalPassport.initialize,
session: originalPassport.session,
serializeUser: originalPassport.serializeUser,
deserializeUser: originalPassport.deserializeUser
};
return passport;
});
// Setup/teardown
beforeAll(async () => {
await mongoose.connect(process.env.TEST_MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true
});
});
afterAll(async () => {
await mongoose.connection.dropDatabase();
await mongoose.connection.close();
});
beforeEach(async () => {
await User.deleteMany({});
});
// Tests
describe('Authentication Routes', () => {
// Test for homepage access
test('should access home page', async () => {
const res = await request(app).get('/');
expect(res.statusCode).toBe(200);
expect(res.text).toContain('Social Login Demo');
});
// Test for login page
test('should access login page', async () => {
const res = await request(app).get('/login');
expect(res.statusCode).toBe(200);
expect(res.text).toContain('Login with');
});
// Test Google OAuth route
test('should redirect to Google OAuth', async () => {
const res = await request(app).get('/auth/google');
expect(res.statusCode).toBe(302); // Redirect status
});
// Test Google OAuth callback
test('should handle Google OAuth callback', async () => {
const res = await request(app)
.get('/auth/google/callback?code=mockcode&state=mockstate');
expect(res.statusCode).toBe(302); // Redirect status
expect(res.headers.location).toBe('/profile');
});
// Test protected route access
test('should deny access to protected route when not authenticated', async () => {
const res = await request(app).get('/profile');
expect(res.statusCode).toBe(302); // Redirect status
expect(res.headers.location).toBe('/login');
});
// Test local registration
test('should register a new user', async () => {
const res = await request(app)
.post('/auth/register')
.send({
name: 'Test User',
email: 'test@example.com',
password: 'password123',
password2: 'password123'
});
expect(res.statusCode).toBe(302); // Redirect status
expect(res.headers.location).toBe('/login');
// Verify user was created in database
const user = await User.findOne({ email: 'test@example.com' });
expect(user).toBeTruthy();
expect(user.name).toBe('Test User');
});
});
Browser Testing Tips
For manual testing in the browser:
- Use Browser DevTools: Inspect network requests, cookies, and localStorage
- Try Incognito Mode: Test login flows without existing cookies or sessions
- Test Different OAuth Scopes: See how different permissions affect available user data
- Test Account Linking: Connect and disconnect various providers
- Check Error Handling: Purposely trigger errors to test recovery mechanisms
- Mobile Testing: Ensure the authentication flow works well on mobile devices
Advanced Social Login Features
Beyond the basic implementation, you can add several advanced features to enhance your social login system:
Role-Based Access Control
// Middleware for role-based access control
// middleware/rbac.js
const roles = {
user: ['read'],
editor: ['read', 'create', 'update'],
admin: ['read', 'create', 'update', 'delete', 'manage']
};
// Check if user has required role
function hasRole(role) {
return (req, res, next) => {
// User must be authenticated
if (!req.isAuthenticated()) {
req.flash('error_msg', 'Please log in to access this resource');
return res.redirect('/login');
}
// Check user role
if (req.user.role !== role) {
req.flash('error_msg', 'You do not have permission to access this resource');
return res.redirect('/');
}
next();
};
}
// Check if user has required permission
function hasPermission(permission) {
return (req, res, next) => {
// User must be authenticated
if (!req.isAuthenticated()) {
req.flash('error_msg', 'Please log in to access this resource');
return res.redirect('/login');
}
// Get permissions for user's role
const userRole = req.user.role || 'user';
const permissions = roles[userRole] || [];
// Check if user has required permission
if (!permissions.includes(permission)) {
req.flash('error_msg', 'You do not have permission to access this resource');
return res.redirect('/');
}
next();
};
}
module.exports = {
hasRole,
hasPermission
};
// Usage example
// routes/adminRoutes.js
const express = require('express');
const router = express.Router();
const { hasRole, hasPermission } = require('../middleware/rbac');
// Admin dashboard - requires admin role
router.get('/dashboard', hasRole('admin'), (req, res) => {
res.render('admin/dashboard');
});
// Manage users - requires manage permission
router.get('/users', hasPermission('manage'), (req, res) => {
// Fetch and display users
});
module.exports = router;
Two-Factor Authentication
// Install required packages
// npm install speakeasy qrcode
// Add 2FA to User model
// models/User.js (additional fields)
const userSchema = new mongoose.Schema({
// ... existing fields
// 2FA fields
twoFactorEnabled: {
type: Boolean,
default: false
},
twoFactorSecret: {
type: String
}
});
// 2FA setup route
// routes/userRoutes.js (additional routes)
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
// Generate 2FA setup
router.get('/setup-2fa', async (req, res, next) => {
try {
// Generate a secret
const secret = speakeasy.generateSecret({
name: `SocialLoginApp:${req.user.email}`
});
// Generate QR code
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
// Store secret temporarily in session
req.session.twoFactorSecret = secret.base32;
// Render setup page with QR code
res.render('setup-2fa', {
qrCodeUrl,
secret: secret.base32
});
} catch (err) {
next(err);
}
});
// Verify and enable 2FA
router.post('/verify-2fa', async (req, res, next) => {
try {
const { token } = req.body;
const secret = req.session.twoFactorSecret;
if (!secret) {
req.flash('error_msg', '2FA setup expired. Please try again.');
return res.redirect('/profile');
}
// Verify token
const verified = speakeasy.totp.verify({
secret,
encoding: 'base32',
token
});
if (!verified) {
req.flash('error_msg', 'Invalid verification code');
return res.redirect('/user/setup-2fa');
}
// Enable 2FA for user
const user = await User.findById(req.user.id);
user.twoFactorEnabled = true;
user.twoFactorSecret = secret;
await user.save();
// Clear secret from session
delete req.session.twoFactorSecret;
req.flash('success_msg', '2FA enabled successfully');
res.redirect('/profile');
} catch (err) {
next(err);
}
});
// Modify login process for 2FA
// routes/authRoutes.js (modified login)
router.post('/login', (req, res, next) => {
passport.authenticate('local', async (err, user, info) => {
if (err) return next(err);
if (!user) {
req.flash('error_msg', info.message);
return res.redirect('/login');
}
// Check if user has 2FA enabled
if (user.twoFactorEnabled) {
// Store user ID in session for 2FA verification
req.session.twoFactorUserId = user.id;
return res.redirect('/auth/verify-2fa');
}
// Standard login (no 2FA)
req.login(user, (err) => {
if (err) return next(err);
return handleAuthRedirect(req, res);
});
})(req, res, next);
});
// 2FA verification page
router.get('/verify-2fa', (req, res) => {
if (!req.session.twoFactorUserId) {
return res.redirect('/login');
}
res.render('verify-2fa');
});
// Verify 2FA code
router.post('/verify-2fa', async (req, res, next) => {
try {
const { token } = req.body;
const userId = req.session.twoFactorUserId;
if (!userId) {
return res.redirect('/login');
}
// Get user
const user = await User.findById(userId);
if (!user) {
delete req.session.twoFactorUserId;
req.flash('error_msg', 'User not found');
return res.redirect('/login');
}
// Verify token
const verified = speakeasy.totp.verify({
secret: user.twoFactorSecret,
encoding: 'base32',
token
});
if (!verified) {
req.flash('error_msg', 'Invalid verification code');
return res.redirect('/auth/verify-2fa');
}
// Clear 2FA session and log in
delete req.session.twoFactorUserId;
req.login(user, (err) => {
if (err) return next(err);
return handleAuthRedirect(req, res);
});
} catch (err) {
next(err);
}
});
Social API Integration
// Fetching additional data from social providers
// services/socialApiService.js
const axios = require('axios');
// Google API service
const googleApi = {
async getUserProfile(accessToken) {
try {
const response = await axios.get('https://www.googleapis.com/oauth2/v3/userinfo', {
headers: {
Authorization: `Bearer ${accessToken}`
}
});
return response.data;
} catch (err) {
console.error('Error fetching Google profile:', err.message);
throw err;
}
},
async getUserContacts(accessToken) {
try {
const response = await axios.get('https://people.googleapis.com/v1/people/me/connections', {
params: {
personFields: 'names,emailAddresses,phoneNumbers',
pageSize: 100
},
headers: {
Authorization: `Bearer ${accessToken}`
}
});
return response.data.connections || [];
} catch (err) {
console.error('Error fetching Google contacts:', err.message);
throw err;
}
}
};
// Facebook API service
const facebookApi = {
async getUserProfile(accessToken) {
try {
const response = await axios.get('https://graph.facebook.com/v12.0/me', {
params: {
fields: 'id,name,email,picture',
access_token: accessToken
}
});
return response.data;
} catch (err) {
console.error('Error fetching Facebook profile:', err.message);
throw err;
}
},
async getUserFriends(accessToken) {
try {
const response = await axios.get('https://graph.facebook.com/v12.0/me/friends', {
params: {
access_token: accessToken
}
});
return response.data.data || [];
} catch (err) {
console.error('Error fetching Facebook friends:', err.message);
throw err;
}
}
};
// Route to fetch contacts from social provider
// routes/userRoutes.js (additional routes)
router.get('/social-contacts', async (req, res, next) => {
try {
if (!req.isAuthenticated()) {
return res.status(401).json({ message: 'Unauthorized' });
}
const userId = req.user.id;
const provider = req.query.provider;
// Check if provider is connected
if (!req.user.hasProvider(provider)) {
return res.status(400).json({ message: `User not connected to ${provider}` });
}
// Get provider data from user
const providerData = req.user.providerData.get(provider);
if (!providerData || !providerData.accessToken) {
return res.status(400).json({ message: 'No access token found' });
}
// Fetch contacts based on provider
let contacts = [];
switch (provider) {
case 'google':
contacts = await googleApi.getUserContacts(providerData.accessToken);
break;
case 'facebook':
contacts = await facebookApi.getUserFriends(providerData.accessToken);
break;
default:
return res.status(400).json({ message: 'Unsupported provider' });
}
res.json({ contacts });
} catch (err) {
// Check for token expiration
if (err.response && err.response.status === 401) {
return res.status(401).json({ message: 'Token expired. Please reconnect your account.' });
}
next(err);
}
});
Remember Me Functionality
// Add Remember Me functionality
// Install required package
// npm install crypto-random-string
// Add RememberToken model
// models/RememberToken.js
const mongoose = require('mongoose');
const crypto = require('crypto-random-string');
const RememberTokenSchema = new mongoose.Schema({
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
token: {
type: String,
required: true,
unique: true
},
userAgent: {
type: String
},
ipAddress: {
type: String
},
expires: {
type: Date,
required: true
},
createdAt: {
type: Date,
default: Date.now
}
});
// Create a remember token
RememberTokenSchema.statics.createToken = async function(userId, userAgent, ipAddress) {
// Generate a unique token
const token = crypto({ length: 64 });
// Set expiration (30 days)
const expires = new Date();
expires.setDate(expires.getDate() + 30);
// Create and return token
const rememberToken = await this.create({
userId,
token,
userAgent,
ipAddress,
expires
});
return rememberToken;
};
// Find token and check if valid
RememberTokenSchema.statics.findValidToken = async function(token) {
return await this.findOne({
token,
expires: { $gt: new Date() }
});
};
const RememberToken = mongoose.model('RememberToken', RememberTokenSchema);
module.exports = RememberToken;
// Modify login route for Remember Me
// routes/authRoutes.js (modified login)
const RememberToken = require('../models/RememberToken');
router.post('/login', (req, res, next) => {
passport.authenticate('local', async (err, user, info) => {
if (err) return next(err);
if (!user) {
req.flash('error_msg', info.message);
return res.redirect('/login');
}
// Handle Remember Me
const rememberMe = req.body.rememberMe === 'on';
// Log in the user
req.login(user, async (err) => {
if (err) return next(err);
// Create remember token if requested
if (rememberMe) {
try {
const rememberToken = await RememberToken.createToken(
user.id,
req.get('User-Agent'),
req.ip
);
// Set remember me cookie (30 days)
res.cookie('remember_token', rememberToken.token, {
httpOnly: true,
path: '/',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax'
});
} catch (err) {
console.error('Error creating remember token:', err);
}
}
return handleAuthRedirect(req, res);
});
})(req, res, next);
});
// Add middleware to check for remember token
// app.js (after session middleware)
const RememberToken = require('./models/RememberToken');
// Remember Me middleware
app.use(async (req, res, next) => {
// Skip if user is already authenticated
if (req.isAuthenticated()) {
return next();
}
// Check for remember token cookie
const token = req.cookies.remember_token;
if (!token) {
return next();
}
try {
// Find valid token
const rememberToken = await RememberToken.findValidToken(token);
if (!rememberToken) {
// Invalid or expired token, clear cookie
res.clearCookie('remember_token');
return next();
}
// Find user
const user = await User.findById(rememberToken.userId);
if (!user) {
// User not found, clear cookie and token
res.clearCookie('remember_token');
await RememberToken.findByIdAndDelete(rememberToken._id);
return next();
}
// Log in the user
req.login(user, (err) => {
if (err) {
console.error('Error logging in with remember token:', err);
return next();
}
// Extend token expiration
rememberToken.expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
rememberToken.save().catch(err => {
console.error('Error extending remember token:', err);
});
next();
});
} catch (err) {
console.error('Remember Me error:', err);
next();
}
});
Security Considerations for Advanced Features
When implementing these advanced features, keep these security considerations in mind:
- Token Expiration: Always set appropriate expiration times for tokens and sessions
- Access Token Storage: Store provider access tokens securely, ideally encrypted
- 2FA Backup Codes: Provide backup codes for users who lose their 2FA device
- Remember Me Security: Use secure, random tokens and store them safely
- Rate Limiting: Implement rate limiting for authentication attempts
- Session Regeneration: Regenerate sessions on privilege changes
These measures help protect user accounts against various attack vectors.
Single Sign-On (SSO) Implementation
For enterprise applications, you might want to implement single sign-on with identity providers like Okta, Auth0, or Azure AD. Here's a simplified example using Passport.js with SAML:
// Install required packages
// npm install passport-saml
// SAML strategy configuration
// config/passport.js (additional strategy)
const SamlStrategy = require('passport-saml').Strategy;
const fs = require('fs');
const path = require('path');
// SAML certificate files
const privateCert = fs.readFileSync(path.join(__dirname, '../certs/private.key'), 'utf8');
const publicCert = fs.readFileSync(path.join(__dirname, '../certs/public.cert'), 'utf8');
// Configure SAML strategy
passport.use(new SamlStrategy({
path: '/auth/saml/callback',
entryPoint: process.env.SAML_ENTRY_POINT,
issuer: process.env.SAML_ISSUER,
cert: process.env.SAML_CERT,
privateCert: privateCert,
decryptionPvk: privateCert,
identifierFormat: null
}, async (profile, done) => {
try {
// Extract profile information
const email = profile.email || profile.nameID;
const name = profile.displayName || `${profile.firstName} ${profile.lastName}`.trim();
// Find user by email
let user = await User.findOne({ email });
if (user) {
// Update user info
user.samlId = profile.nameID;
user.updatedAt = Date.now();
await user.save();
} else {
// Create new user
user = await User.create({
samlId: profile.nameID,
name,
email,
emailVerified: true
});
}
return done(null, user);
} catch (err) {
return done(err);
}
}));
// SAML routes
// routes/authRoutes.js (additional routes)
router.get('/saml', passport.authenticate('saml'));
router.post('/saml/callback',
passport.authenticate('saml', { failureRedirect: '/login' }),
handleAuthRedirect
);
// SAML metadata endpoint
router.get('/saml/metadata', (req, res) => {
const samlStrategy = passport._strategies.saml;
const metadata = samlStrategy.generateServiceProviderMetadata(publicCert);
res.header('Content-Type', 'text/xml').send(metadata);
});
Enterprise SSO Providers
For larger organizations, specialized identity providers offer comprehensive SSO solutions:
- Okta: Supports SAML, OIDC, and WS-Federation with extensive enterprise features
- Auth0: Provides a flexible identity platform with support for multiple protocols
- Azure AD: Microsoft's identity solution with seamless integration with Office 365
- OneLogin: Enterprise identity solution with strong compliance features
- Keycloak: Open-source identity solution with comprehensive SSO capabilities
These platforms can be integrated using specialized Passport strategies or their own SDKs.
Performance and Scalability Considerations
As your authentication system grows, you'll need to consider performance and scalability:
Caching Strategies
// Install Redis for caching
// npm install redis
// Configure Redis cache
// services/cacheService.js
const redis = require('redis');
const { promisify } = require('util');
// Create Redis client
const client = redis.createClient({
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
password: process.env.REDIS_PASSWORD || '',
db: process.env.REDIS_DB || 0
});
// Promisify Redis commands
const getAsync = promisify(client.get).bind(client);
const setAsync = promisify(client.set).bind(client);
const delAsync = promisify(client.del).bind(client);
const expireAsync = promisify(client.expire).bind(client);
// Handle Redis errors
client.on('error', (err) => {
console.error('Redis error:', err);
});
// Cache utility functions
const cache = {
// Get data from cache
async get(key) {
try {
const data = await getAsync(key);
return data ? JSON.parse(data) : null;
} catch (err) {
console.error('Cache get error:', err);
return null;
}
},
// Set data in cache with optional TTL
async set(key, data, ttl = 3600) {
try {
await setAsync(key, JSON.stringify(data));
if (ttl) {
await expireAsync(key, ttl);
}
return true;
} catch (err) {
console.error('Cache set error:', err);
return false;
}
},
// Delete data from cache
async del(key) {
try {
await delAsync(key);
return true;
} catch (err) {
console.error('Cache delete error:', err);
return false;
}
}
};
module.exports = cache;
// Use caching for user profile data
// controllers/userController.js
const cache = require('../services/cacheService');
// Get user profile with caching
async function getUserProfile(userId) {
try {
// Check cache first
const cacheKey = `user_profile:${userId}`;
const cachedProfile = await cache.get(cacheKey);
if (cachedProfile) {
return cachedProfile;
}
// Not in cache, fetch from database
const user = await User.findById(userId);
if (!user) {
return null;
}
// Create profile object
const profile = {
id: user._id,
name: user.name,
email: user.email,
avatar: user.avatar,
providers: user.getProviders(),
createdAt: user.createdAt
};
// Cache profile (1 hour TTL)
await cache.set(cacheKey, profile, 3600);
return profile;
} catch (err) {
console.error('Error getting user profile:', err);
throw err;
}
}
// Clear cache when user is updated
async function updateUserProfile(userId, updateData) {
try {
// Update user in database
const user = await User.findByIdAndUpdate(userId, {
...updateData,
updatedAt: Date.now()
}, { new: true });
if (!user) {
return null;
}
// Clear user cache
const cacheKey = `user_profile:${userId}`;
await cache.del(cacheKey);
return user;
} catch (err) {
console.error('Error updating user profile:', err);
throw err;
}
}
Database Optimization
// Database indexes for optimized queries
// models/User.js (adding indexes)
const UserSchema = new mongoose.Schema({
// ... existing fields
});
// Create indexes for frequently queried fields
UserSchema.index({ email: 1 });
UserSchema.index({ googleId: 1 });
UserSchema.index({ facebookId: 1 });
UserSchema.index({ githubId: 1 });
UserSchema.index({ twitterId: 1 });
// Compound index for role-based queries
UserSchema.index({ role: 1, createdAt: -1 });
// Connection pooling configuration
// config/database.js (optimized connection)
const connectDB = async () => {
try {
const conn = await mongoose.connect(process.env.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
useFindAndModify: false,
useCreateIndex: true,
// Connection pool settings
poolSize: 10,
socketTimeoutMS: 45000,
keepAlive: true,
keepAliveInitialDelay: 300000
});
console.log(`MongoDB Connected: ${conn.connection.host}`);
} catch (err) {
console.error(`Error connecting to MongoDB: ${err.message}`);
process.exit(1);
}
};
Horizontal Scaling
// Load balancing and session configuration
// app.js (modified for scaling)
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis')(session);
const redis = require('redis');
const cluster = require('cluster');
const numCPUs = require('os').cpus().length;
require('dotenv').config();
if (cluster.isMaster && process.env.NODE_ENV === 'production') {
console.log(`Master ${process.pid} is running`);
// Fork workers
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} died`);
// Replace the dead worker
cluster.fork();
});
} else {
// Worker process
const app = express();
// Create Redis client for session store
const redisClient = redis.createClient({
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT,
password: process.env.REDIS_PASSWORD
});
// Configure session with Redis store for scaling
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
maxAge: 24 * 60 * 60 * 1000,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax'
}
}));
// ... rest of app configuration
// Start server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Worker ${process.pid} started on port ${PORT}`);
});
}
Cloud Deployment Optimization
For high-traffic applications, consider these cloud deployment optimizations:
- Containerization: Use Docker to package the application for consistent deployment
- Kubernetes: Orchestrate containers for automatic scaling and high availability
- CDN Integration: Use content delivery networks for static assets
- Managed Services: Use managed MongoDB (Atlas) and Redis (ElastiCache) services
- Auto-scaling: Configure auto-scaling based on traffic patterns
- Health Checks: Implement thorough health monitoring for all components
These optimizations ensure your authentication system remains responsive under varying loads.
Practice Activities
Activity 1: Multi-Provider Social Login
Implement a complete social login system with multiple providers:
- Set up the project structure with Express, MongoDB, and Passport.js
- Implement authentication with at least three providers (Google, GitHub, and one other)
- Create a user model that supports multiple authentication methods
- Implement account linking and unlinking functionality
- Add a profile page showing connected providers
- Implement proper error handling and user feedback
Bonus: Add email/password authentication alongside social login
Activity 2: Advanced Authentication Features
Enhance your authentication system with advanced features:
- Implement two-factor authentication using time-based one-time passwords (TOTP)
- Add "Remember Me" functionality for persistent sessions
- Implement role-based access control with at least three roles
- Create an admin dashboard for managing users
- Add session management showing all active sessions for a user
- Implement account recovery mechanisms
Bonus: Add email verification for new user registrations
Activity 3: Performance Optimization
Optimize your authentication system for performance and scalability:
- Implement Redis caching for user profiles and sessions
- Add database indexes for frequently queried fields
- Configure connection pooling for database connections
- Implement rate limiting for authentication requests
- Add horizontal scaling support with cluster mode
- Set up monitoring for authentication performance
Bonus: Create a load testing script to benchmark your optimizations
Activity 4: OAuth Provider Integration
Extend your social login system with OAuth API integration:
- Implement Google contacts import using OAuth scopes
- Add GitHub repository listing using the GitHub API
- Implement automatic profile picture sync from social providers
- Add friend finder functionality that matches users with shared connections
- Implement token refresh mechanism for expired OAuth tokens
- Create a dashboard showing connected services and available data
Bonus: Add an activity feed showing actions across connected services
Additional Resources
Summary
- Social login provides a seamless authentication experience using existing accounts
- A flexible user model is essential for supporting multiple authentication methods
- Passport.js provides a consistent interface for various authentication strategies
- Account linking allows users to connect multiple social accounts to a single profile
- Security considerations include proper session management, cookie settings, and data protection
- Advanced features like 2FA, role-based access control, and Remember Me enhance security and UX
- Performance optimization through caching, database tuning, and horizontal scaling is important for high-traffic applications
- Proper testing and debugging strategies help ensure a robust authentication system
By implementing these concepts, you can create a secure, user-friendly authentication system that leverages social providers while maintaining flexibility for future enhancements.