Introduction to Passport.js
Passport.js is a powerful authentication middleware for Node.js applications that simplifies the implementation of various authentication strategies. It's designed to be flexible, modular, and work with any Express-based web application.
Why Use Passport.js?
Authentication is a critical component of most web applications, and Passport makes it easier by:
- Simplifying authentication logic with a consistent, reusable pattern
- Supporting numerous authentication mechanisms through "strategies"
- Separating authentication concerns from your application logic
- Providing a unified API across different authentication methods
- Integrating seamlessly with Express.js middleware pipeline
Think of Passport as a Swiss Army knife for authentication—one tool with many specialized attachments for different scenarios.
Core Concepts
- Strategies: Plugins that implement specific authentication mechanisms (Local, OAuth, OpenID, etc.)
- Middleware: Passport functions that integrate into Express.js request pipeline
- Serialization/Deserialization: Methods to store and retrieve user data from sessions
- Sessions: Optional persistent login state across requests
- Authenticate: Core method that initiates the authentication process
Setting Up Passport.js
Let's start by setting up Passport in an Express.js application:
// Install required packages
// npm install express express-session passport
// Basic Passport setup in Express
const express = require('express');
const session = require('express-session');
const passport = require('passport');
const app = express();
// Configure session middleware (required for persistent login sessions)
app.use(session({
secret: process.env.SESSION_SECRET || 'your-secret-key',
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
// Initialize Passport and restore authentication state from session
app.use(passport.initialize());
app.use(passport.session());
// Serialize user object to store in session
passport.serializeUser((user, done) => {
// Store only the user ID in the session
done(null, user.id);
});
// Deserialize user from session ID
passport.deserializeUser(async (id, done) => {
try {
// Fetch user from database
const user = await User.findById(id);
done(null, user);
} catch (err) {
done(err);
}
});
// Routes
app.get('/', (req, res) => {
res.send('Home Page');
});
// Protected route example
app.get('/profile', isAuthenticated, (req, res) => {
res.send(`Welcome, ${req.user.username}!`);
});
// Middleware to check if user is authenticated
function isAuthenticated(req, res, next) {
if (req.isAuthenticated()) {
return next();
}
res.redirect('/login');
}
// Start server
app.listen(3000, () => {
console.log('Server running on port 3000');
});
Understanding the Setup
Let's break down the key components:
- express-session: Required for persistent login sessions
- passport.initialize(): Sets up Passport for authentication
- passport.session(): Enables persistent login sessions
- serializeUser(): Determines what user data is stored in the session
- deserializeUser(): Retrieves user from database based on session data
- req.isAuthenticated(): Checks if user is logged in
This setup establishes the foundation for any Passport-based authentication, regardless of which strategies you'll use.
Local Authentication Strategy
The Local strategy is the most basic authentication mechanism, using username and password stored in your application's database.
// Install required packages
// npm install passport-local bcrypt
const LocalStrategy = require('passport-local').Strategy;
const bcrypt = require('bcrypt');
const User = require('./models/User'); // Your user model
// Configure Local Strategy
passport.use(new LocalStrategy(
async (username, password, done) => {
try {
// Find user by username
const user = await User.findOne({ username });
// User not found
if (!user) {
return done(null, false, { message: 'Incorrect username' });
}
// Compare password with stored hash
const isMatch = await bcrypt.compare(password, user.password);
// Password doesn't match
if (!isMatch) {
return done(null, false, { message: 'Incorrect password' });
}
// Authentication successful
return done(null, user);
} catch (err) {
return done(err);
}
}
));
// Login route
app.post('/login',
passport.authenticate('local', {
successRedirect: '/profile',
failureRedirect: '/login',
failureFlash: true // Requires express-flash for displaying messages
})
);
// Registration route
app.post('/register', async (req, res) => {
try {
const { username, email, password } = req.body;
// Check if user already exists
const existingUser = await User.findOne({
$or: [{ username }, { email }]
});
if (existingUser) {
return res.status(400).send('Username or email already exists');
}
// Hash the password
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
// Create a new user
const newUser = new User({
username,
email,
password: hashedPassword
});
await newUser.save();
// Redirect to login page
res.redirect('/login');
} catch (err) {
res.status(500).send('Server error');
}
});
// Logout route
app.get('/logout', (req, res) => {
req.logout(function(err) {
if (err) { return next(err); }
res.redirect('/');
});
});
How Local Authentication Works
The local authentication flow:
- User submits credentials via a login form
- Passport's middleware processes the request
- The LocalStrategy validates credentials against the database
- If valid, the user object is passed to
serializeUser() - The session is established with the serialized user data
- User is redirected to the success page
On subsequent requests, Passport retrieves the user ID from the session, uses deserializeUser() to fetch the complete user object, and makes it available as req.user.
Example User Model
// models/User.js
const mongoose = require('mongoose');
const userSchema = new mongoose.Schema({
username: {
type: String,
required: true,
unique: true,
trim: true,
minlength: 3
},
email: {
type: String,
required: true,
unique: true,
trim: true,
lowercase: true
},
password: {
type: String,
required: true,
minlength: 6
},
role: {
type: String,
enum: ['user', 'admin'],
default: 'user'
},
createdAt: {
type: Date,
default: Date.now
}
});
const User = mongoose.model('User', userSchema);
module.exports = User;
Custom Authentication Fields
If your login form doesn't use the default "username" and "password" field names, you can customize the LocalStrategy:
// Custom field names
passport.use(new LocalStrategy({
usernameField: 'email', // Using email instead of username
passwordField: 'pass' // Using 'pass' instead of 'password'
},
async (email, password, done) => {
try {
// Find user by email
const user = await User.findOne({ email });
// User validation logic...
return done(null, user);
} catch (err) {
return done(err);
}
}
));
OAuth Strategies
Passport makes implementing OAuth authentication with various providers straightforward. Let's look at how to implement Google, Facebook, and GitHub OAuth strategies:
Google OAuth 2.0
// Install required packages
// npm install passport-google-oauth20
const GoogleStrategy = require('passport-google-oauth20').Strategy;
// Configure Google Strategy
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: 'http://localhost:3000/auth/google/callback',
scope: ['profile', 'email']
},
async (accessToken, refreshToken, profile, done) => {
try {
// Check if user exists in database
let user = await User.findOne({ googleId: profile.id });
if (!user) {
// Create new user if not found
user = await User.create({
googleId: profile.id,
username: profile.displayName,
email: profile.emails[0].value,
picture: profile.photos[0].value
// No password for OAuth users
});
}
// Return user object
return done(null, user);
} catch (err) {
return done(err);
}
}
));
// Google Auth Routes
app.get('/auth/google',
passport.authenticate('google', { scope: ['profile', 'email'] })
);
app.get('/auth/google/callback',
passport.authenticate('google', {
successRedirect: '/profile',
failureRedirect: '/login'
})
);
Facebook OAuth
// Install required packages
// npm install passport-facebook
const FacebookStrategy = require('passport-facebook').Strategy;
// Configure Facebook Strategy
passport.use(new FacebookStrategy({
clientID: process.env.FACEBOOK_APP_ID,
clientSecret: process.env.FACEBOOK_APP_SECRET,
callbackURL: 'http://localhost:3000/auth/facebook/callback',
profileFields: ['id', 'displayName', 'email', 'photos']
},
async (accessToken, refreshToken, profile, done) => {
try {
// Check if user exists
let user = await User.findOne({ facebookId: profile.id });
if (!user) {
// Create new user
user = await User.create({
facebookId: profile.id,
username: profile.displayName,
email: profile.emails ? profile.emails[0].value : `${profile.id}@facebook.com`,
picture: profile.photos ? profile.photos[0].value : null
});
}
return done(null, user);
} catch (err) {
return done(err);
}
}
));
// Facebook Auth Routes
app.get('/auth/facebook',
passport.authenticate('facebook', { scope: ['email'] })
);
app.get('/auth/facebook/callback',
passport.authenticate('facebook', {
successRedirect: '/profile',
failureRedirect: '/login'
})
);
GitHub OAuth
// Install required packages
// npm install passport-github2
const GitHubStrategy = require('passport-github2').Strategy;
// Configure GitHub Strategy
passport.use(new GitHubStrategy({
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: 'http://localhost:3000/auth/github/callback',
scope: ['user:email']
},
async (accessToken, refreshToken, profile, done) => {
try {
// Check if user exists
let user = await User.findOne({ githubId: profile.id });
if (!user) {
// Create new user
user = await User.create({
githubId: profile.id,
username: profile.username,
email: profile.emails ? profile.emails[0].value : `${profile.username}@github.com`,
picture: profile.photos ? profile.photos[0].value : null
});
}
return done(null, user);
} catch (err) {
return done(err);
}
}
));
// GitHub Auth Routes
app.get('/auth/github',
passport.authenticate('github', { scope: ['user:email'] })
);
app.get('/auth/github/callback',
passport.authenticate('github', {
successRedirect: '/profile',
failureRedirect: '/login'
})
);
Modified User Model for OAuth
To support multiple authentication methods, your User model needs to be modified:
const userSchema = new mongoose.Schema({
username: String,
email: {
type: String,
unique: true
},
password: String, // May be null for OAuth users
picture: String,
role: {
type: String,
enum: ['user', 'admin'],
default: 'user'
},
// OAuth provider IDs
googleId: String,
facebookId: String,
githubId: String,
createdAt: {
type: Date,
default: Date.now
}
});
This allows a single user to authenticate through multiple methods, with the email address typically used as the common identifier.
Account Linking
Users often want to connect multiple authentication methods to a single account. Here's how to implement account linking with Passport:
// Account linking - connecting OAuth provider to existing account
app.get('/connect/google',
// Ensure user is already authenticated
isAuthenticated,
// Start Google OAuth flow, but set it to connect mode
passport.authorize('google', { scope: ['profile', 'email'] })
);
app.get('/connect/google/callback',
isAuthenticated,
passport.authorize('google', {
successRedirect: '/profile',
failureRedirect: '/profile'
})
);
// Modified Google Strategy for account linking
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: 'http://localhost:3000/connect/google/callback',
passReqToCallback: true // Pass request to callback
},
async (req, accessToken, refreshToken, profile, done) => {
try {
// Check if there's an authenticated user (account linking case)
if (req.user) {
// Add Google ID to existing user
const existingUser = req.user;
existingUser.googleId = profile.id;
// Optionally, update user profile with additional info
if (!existingUser.picture && profile.photos && profile.photos.length) {
existingUser.picture = profile.photos[0].value;
}
await existingUser.save();
return done(null, existingUser);
}
// Normal authentication case (not account linking)
// ... existing authentication logic
} catch (err) {
return done(err);
}
}
));
// Route to unlink accounts
app.get('/unlink/google', isAuthenticated, async (req, res) => {
try {
const user = await User.findById(req.user.id);
user.googleId = undefined;
await user.save();
res.redirect('/profile');
} catch (err) {
res.status(500).send('Error unlinking account');
}
});
Account Linking vs. Authentication
Note the key differences between linking and authentication:
- Authentication (
passport.authenticate()) creates a new user session if none exists - Authorization (
passport.authorize()) connects a provider to an already authenticated user - We use
passReqToCallback: trueto access the existing user in the strategy - Different callback URLs are typically used to distinguish these flows
This approach allows users to sign in with different methods and maintain a single account.
JWT Strategy
For stateless API authentication, Passport provides a JWT strategy that works well with token-based authentication:
// Install required packages
// npm install passport-jwt jsonwebtoken
const JwtStrategy = require('passport-jwt').Strategy;
const ExtractJwt = require('passport-jwt').ExtractJwt;
// JWT Strategy options
const jwtOptions = {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), // Extract token from Authorization header
secretOrKey: process.env.JWT_SECRET // Secret key for verification
};
// Configure JWT Strategy
passport.use(new JwtStrategy(jwtOptions, async (payload, done) => {
try {
// Find user by ID in payload
const user = await User.findById(payload.sub);
if (!user) {
return done(null, false);
}
return done(null, user);
} catch (err) {
return done(err, false);
}
}));
// Route to generate JWT token (typically after login)
app.post('/api/login', async (req, res) => {
try {
const { email, password } = req.body;
// Find user
const user = await User.findOne({ email });
// Check if user exists and password matches
if (!user || !(await user.comparePassword(password))) {
return res.status(401).json({ message: 'Invalid credentials' });
}
// Generate JWT token
const token = jwt.sign(
{ sub: user._id },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
// Return token to client
res.json({ token });
} catch (err) {
res.status(500).json({ message: 'Server error' });
}
});
// Protected API route using JWT authentication
app.get('/api/profile',
passport.authenticate('jwt', { session: false }), // Don't create session
(req, res) => {
res.json(req.user);
}
);
JWT Strategy Features
The JWT strategy differs from other strategies:
- It's stateless - no session is created, suitable for APIs
- Authentication happens on every request by verifying the token
- Allows different extraction methods for the token (header, query parameter, cookie)
- Enables microservice architecture where different services can validate the same token
Note the { session: false } option which tells Passport not to create a session, maintaining stateless authentication.
JWT Extraction Methods
// Different JWT extraction methods
const jwtOptions = {
// From Authorization header (Bearer scheme)
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
// From Authorization header (custom scheme)
// jwtFromRequest: ExtractJwt.fromAuthHeaderWithScheme('JWT'),
// From a specific query parameter
// jwtFromRequest: ExtractJwt.fromUrlQueryParameter('token'),
// From a cookie
// jwtFromRequest: ExtractJwt.fromExtractors([
// (req) => {
// let token = null;
// if (req && req.cookies) {
// token = req.cookies['jwt'];
// }
// return token;
// }
// ]),
secretOrKey: process.env.JWT_SECRET
};
Custom Strategies
Passport allows you to create custom authentication strategies for specific requirements. Let's implement a simple API key strategy:
// Creating a custom API Key strategy
const passport = require('passport');
const { Strategy } = require('passport-strategy');
// Define custom ApiKeyStrategy
class ApiKeyStrategy extends Strategy {
constructor(options, verify) {
super();
this.name = 'apikey'; // Strategy name
this.verify = verify;
this.apiKeyField = options.apiKeyField || 'api_key';
this.passReqToCallback = options.passReqToCallback;
}
authenticate(req) {
// Get API key from request
const apiKey = req.get('X-API-Key') || req.query[this.apiKeyField] || req.body[this.apiKeyField];
// No API key provided
if (!apiKey) {
return this.fail({ message: 'No API key provided' }, 401);
}
// Create verify callback
const verified = (err, user, info) => {
if (err) { return this.error(err); }
if (!user) { return this.fail(info); }
this.success(user, info);
};
// Execute verify function
if (this.passReqToCallback) {
this.verify(req, apiKey, verified);
} else {
this.verify(apiKey, verified);
}
}
}
// Register the strategy
passport.use(new ApiKeyStrategy(
{ apiKeyField: 'api_key' },
async (apiKey, done) => {
try {
// Find API client by key
const apiClient = await ApiClient.findOne({ apiKey });
if (!apiClient) {
return done(null, false, { message: 'Unknown API key' });
}
if (!apiClient.active) {
return done(null, false, { message: 'API key is inactive' });
}
// Log API key usage
apiClient.lastUsed = new Date();
await apiClient.save();
return done(null, apiClient);
} catch (err) {
return done(err);
}
}
));
// API route using custom strategy
app.get('/api/data',
passport.authenticate('apikey', { session: false }),
(req, res) => {
res.json({ data: 'Protected API data' });
}
);
When to Create Custom Strategies
Consider creating a custom strategy when:
- You need a specialized authentication method not covered by existing strategies
- Your application has unique security requirements
- You're integrating with a proprietary authentication system
- You want to validate tokens or credentials in a custom way
Custom strategies follow the same pattern as built-in strategies, making them consistent and reusable across applications.
Multiple Strategy Configuration
Most applications benefit from supporting multiple authentication strategies. Here's how to implement and manage them effectively:
// Configuring multiple strategies
// Local Strategy
passport.use('local', new LocalStrategy(
// Local strategy configuration...
));
// Google Strategy
passport.use('google', new GoogleStrategy(
// Google strategy configuration...
));
// JWT Strategy
passport.use('jwt', new JwtStrategy(
// JWT strategy configuration...
));
// Common verify functions
async function findOrCreateOAuthUser(provider, profile) {
// Standardized function to find or create users from OAuth profiles
try {
// First, try to find by provider ID
const providerIdField = `${provider}Id`; // e.g., 'googleId', 'facebookId'
let user = await User.findOne({ [providerIdField]: profile.id });
// If not found, try to find by email
if (!user && profile.emails && profile.emails.length) {
const email = profile.emails[0].value;
user = await User.findOne({ email });
if (user) {
// User exists, link this provider
user[providerIdField] = profile.id;
await user.save();
} else {
// Create new user
user = await User.create({
[providerIdField]: profile.id,
username: profile.displayName || profile.username,
email: profile.emails[0].value,
picture: profile.photos?.[0]?.value
});
}
}
return user;
} catch (err) {
throw err;
}
}
// Login routes that specify which strategy to use
app.post('/login',
passport.authenticate('local', {
successRedirect: '/profile',
failureRedirect: '/login',
failureFlash: true
})
);
app.get('/auth/google',
passport.authenticate('google', { scope: ['profile', 'email'] })
);
app.get('/api/profile',
passport.authenticate(['jwt', 'bearer'], { session: false }),
(req, res) => {
res.json(req.user);
}
);
Strategy Selection Logic
Passport can attempt multiple strategies for a single route:
// Try multiple strategies in order (first one that succeeds wins)
app.get('/api/data',
passport.authenticate(['jwt', 'apikey'], { session: false }),
(req, res) => {
res.json({ data: 'Protected data' });
}
);
This allows clients to authenticate using either method, providing flexibility while maintaining security.
Strategy Naming and Reuse
// Using named strategies for different configurations
// Regular login strategy
passport.use('local-login', new LocalStrategy(
{
usernameField: 'email',
passwordField: 'password'
},
async (email, password, done) => {
// Login verification logic...
}
));
// Admin login strategy with additional security
passport.use('local-admin', new LocalStrategy(
{
usernameField: 'email',
passwordField: 'password'
},
async (email, password, done) => {
try {
const user = await User.findOne({ email });
// Verify password
if (!user || !(await user.comparePassword(password))) {
return done(null, false, { message: 'Invalid credentials' });
}
// Check if user is an admin
if (user.role !== 'admin') {
return done(null, false, { message: 'Admin access required' });
}
// Check IP address or other security factors
return done(null, user);
} catch (err) {
return done(err);
}
}
));
// Use specific named strategies in routes
app.post('/login',
passport.authenticate('local-login', {
successRedirect: '/profile',
failureRedirect: '/login'
})
);
app.post('/admin/login',
passport.authenticate('local-admin', {
successRedirect: '/admin/dashboard',
failureRedirect: '/admin/login'
})
);
Advanced Passport Features
Dynamic Strategy Configuration
// Dynamic strategy configuration based on database settings
async function configurePassport() {
try {
// Get OAuth providers from database
const providers = await OAuthProvider.find({ active: true });
// Configure each provider dynamically
for (const provider of providers) {
switch (provider.name) {
case 'google':
passport.use(new GoogleStrategy({
clientID: provider.clientId,
clientSecret: provider.clientSecret,
callbackURL: provider.callbackURL,
scope: provider.scope.split(',')
}, commonOAuthCallback));
break;
case 'facebook':
passport.use(new FacebookStrategy({
clientID: provider.clientId,
clientSecret: provider.clientSecret,
callbackURL: provider.callbackURL,
profileFields: ['id', 'displayName', 'photos', 'email']
}, commonOAuthCallback));
break;
// Add more providers as needed
}
}
console.log('Passport strategies configured successfully');
} catch (err) {
console.error('Error configuring Passport:', err);
}
}
// Common OAuth callback function
async function commonOAuthCallback(accessToken, refreshToken, profile, done) {
try {
// Determine provider from profile.provider (set by Passport)
const provider = profile.provider; // 'google', 'facebook', etc.
// Use common function to find or create user
const user = await findOrCreateOAuthUser(provider, profile);
return done(null, user);
} catch (err) {
return done(err);
}
}
// Call the configuration function during app startup
configurePassport();
Custom Authentication Error Handling
// Custom error handling with Passport
app.post('/login', (req, res, next) => {
passport.authenticate('local', (err, user, info) => {
if (err) {
return next(err); // Server error
}
if (!user) {
// Authentication failed
// Instead of redirecting, return a JSON response
return res.status(401).json({
success: false,
message: info.message || 'Authentication failed'
});
}
// Log in the user manually
req.login(user, (loginErr) => {
if (loginErr) {
return next(loginErr);
}
// Success response
return res.json({
success: true,
user: {
id: user._id,
username: user.username,
email: user.email
}
});
});
})(req, res, next);
});
// API login with detailed error handling
app.post('/api/login', (req, res, next) => {
passport.authenticate('local', (err, user, info) => {
if (err) {
return res.status(500).json({
status: 'error',
code: 'server_error',
message: 'Internal server error',
details: process.env.NODE_ENV === 'development' ? err.message : undefined
});
}
if (!user) {
// Determine specific error type
let errorCode = 'authentication_failed';
let statusCode = 401;
if (info && info.message) {
if (info.message.includes('not found')) {
errorCode = 'user_not_found';
} else if (info.message.includes('password')) {
errorCode = 'invalid_password';
} else if (info.message.includes('locked')) {
errorCode = 'account_locked';
statusCode = 403;
}
}
return res.status(statusCode).json({
status: 'error',
code: errorCode,
message: info.message || 'Authentication failed'
});
}
// Generate JWT instead of session
const token = jwt.sign(
{ sub: user._id },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
return res.json({
status: 'success',
data: {
token,
user: {
id: user._id,
username: user.username,
email: user.email
}
}
});
})(req, res, next);
});
Middleware Composition
// Composing multiple middleware for authentication
function rateLimit(max, timeWindow) {
const requests = {};
return (req, res, next) => {
const ip = req.ip;
// Initialize or get request count
requests[ip] = requests[ip] || { count: 0, resetTime: Date.now() + timeWindow };
// Reset counter if time window has passed
if (Date.now() > requests[ip].resetTime) {
requests[ip] = { count: 0, resetTime: Date.now() + timeWindow };
}
// Increment request count
requests[ip].count++;
// Check if rate limit exceeded
if (requests[ip].count > max) {
return res.status(429).json({ message: 'Too many login attempts' });
}
next();
};
}
// Apply rate limiting before Passport authentication
app.post('/login',
rateLimit(5, 60 * 1000), // 5 attempts per minute
passport.authenticate('local', {
successRedirect: '/profile',
failureRedirect: '/login',
failureFlash: true
})
);
// More complex middleware composition
app.post('/api/admin/login',
// Check if admin logins are enabled
(req, res, next) => {
if (process.env.DISABLE_ADMIN_LOGIN === 'true') {
return res.status(503).json({ message: 'Admin login temporarily disabled' });
}
next();
},
// Apply stricter rate limiting for admin
rateLimit(3, 5 * 60 * 1000), // 3 attempts per 5 minutes
// Log all admin login attempts
(req, res, next) => {
console.log(`Admin login attempt from ${req.ip} for ${req.body.email}`);
next();
},
// Use custom authentication handling
(req, res, next) => {
passport.authenticate('local-admin', (err, user, info) => {
// Custom auth handling...
})(req, res, next);
}
);
Testing Passport.js Applications
Testing authentication is crucial to ensure your security implementation works correctly:
// Testing Passport authentication with Jest and Supertest
const request = require('supertest');
const app = require('../app'); // Your Express app
const User = require('../models/User');
const mongoose = require('mongoose');
// Test database connection
beforeAll(async () => {
await mongoose.connect(process.env.TEST_MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true
});
});
// Clean up after tests
afterAll(async () => {
await mongoose.connection.dropDatabase();
await mongoose.connection.close();
});
// Clear users before each test
beforeEach(async () => {
await User.deleteMany({});
});
describe('Authentication API', () => {
// Test user registration
test('should register a new user', async () => {
const response = await request(app)
.post('/register')
.send({
username: 'testuser',
email: 'test@example.com',
password: 'password123'
});
expect(response.status).toBe(302); // Redirect after registration
expect(response.headers.location).toBe('/login');
// Verify user was created in database
const user = await User.findOne({ email: 'test@example.com' });
expect(user).toBeTruthy();
expect(user.username).toBe('testuser');
});
// Test login with valid credentials
test('should log in with valid credentials', async () => {
// Create a test user
const password = 'password123';
const bcrypt = require('bcrypt');
const hashedPassword = await bcrypt.hash(password, 10);
await User.create({
username: 'testuser',
email: 'test@example.com',
password: hashedPassword
});
// Attempt login
const response = await request(app)
.post('/login')
.send({
username: 'testuser',
password: password
});
expect(response.status).toBe(302); // Redirect after login
expect(response.headers.location).toBe('/profile');
// Verify session cookie was set
expect(response.headers['set-cookie']).toBeDefined();
});
// Test login with invalid credentials
test('should reject invalid credentials', async () => {
// Create a test user
const password = 'password123';
const bcrypt = require('bcrypt');
const hashedPassword = await bcrypt.hash(password, 10);
await User.create({
username: 'testuser',
email: 'test@example.com',
password: hashedPassword
});
// Attempt login with wrong password
const response = await request(app)
.post('/login')
.send({
username: 'testuser',
password: 'wrongpassword'
});
expect(response.status).toBe(302); // Redirect after failed login
expect(response.headers.location).toBe('/login'); // Back to login page
});
// Test accessing protected route
test('should access protected route with authentication', async () => {
// Create a test user
const user = await User.create({
username: 'testuser',
email: 'test@example.com',
password: await bcrypt.hash('password123', 10)
});
// Login to get cookies
const loginResponse = await request(app)
.post('/login')
.send({
username: 'testuser',
password: 'password123'
});
const cookies = loginResponse.headers['set-cookie'];
// Try accessing protected route with cookie
const protectedResponse = await request(app)
.get('/profile')
.set('Cookie', cookies);
expect(protectedResponse.status).toBe(200);
expect(protectedResponse.text).toContain('Welcome, testuser');
});
// Test accessing protected route without authentication
test('should reject unauthenticated access to protected route', async () => {
const response = await request(app).get('/profile');
expect(response.status).toBe(302); // Redirect
expect(response.headers.location).toBe('/login');
});
});
Testing OAuth Strategies
// Testing OAuth strategies
const passport = require('passport');
// Mock the Google strategy for testing
jest.mock('passport-google-oauth20');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
describe('OAuth Authentication', () => {
beforeEach(() => {
// Clear all mocks
jest.clearAllMocks();
// Mock strategy implementation
GoogleStrategy.mockImplementation((options, verify) => {
// Store the verify callback for testing
this.verifyCallback = verify;
// Return mock strategy instance
return {
name: 'google',
authenticate: jest.fn()
};
});
});
test('should initialize Google strategy with correct options', () => {
// Import the module that configures passport
require('../config/passport');
// Check that GoogleStrategy was constructed with correct options
expect(GoogleStrategy).toHaveBeenCalledWith(
expect.objectContaining({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: expect.stringContaining('/auth/google/callback')
}),
expect.any(Function)
);
});
test('should create a new user from Google profile', async () => {
// Import the module that configures passport
require('../config/passport');
// Get the verify callback
const verifyCallback = GoogleStrategy.mock.calls[0][1];
// Create a mock profile
const profile = {
id: 'google123',
displayName: 'Test User',
emails: [{ value: 'google-user@example.com' }],
photos: [{ value: 'https://example.com/photo.jpg' }]
};
// Create mock done callback
const done = jest.fn();
// Call the verify function
await verifyCallback('test-token', 'test-refresh-token', profile, done);
// Verify new user was created
const user = await User.findOne({ googleId: 'google123' });
expect(user).toBeTruthy();
expect(user.email).toBe('google-user@example.com');
// Verify done was called with user
expect(done).toHaveBeenCalledWith(null, expect.objectContaining({
googleId: 'google123'
}));
});
});
Best Practices and Security Considerations
When implementing Passport.js for authentication, keep these best practices in mind:
Security Checklist
- Use HTTPS for all authentication-related traffic
- Properly hash passwords (bcrypt, argon2) with appropriate work factors
- Set secure cookie options (HttpOnly, Secure, SameSite, etc.)
- Implement rate limiting for login attempts
- Use short-lived tokens with proper refresh mechanisms
- Validate and sanitize all user inputs
- Keep strategy-specific secrets in environment variables
- Implement account lockout mechanisms after multiple failed attempts
- Enable multifactor authentication for sensitive operations
- Regularly audit authentication logs for suspicious activity
Additional Best Practices
- Strategy Selection: Choose strategies that align with your security requirements
- Error Messaging: Use generic error messages that don't reveal whether the username or password was incorrect
- Session Management: Implement proper session regeneration, expiration, and invalidation
- Account Recovery: Provide secure password reset and account recovery workflows
- Audit Trails: Log authentication events, especially sensitive actions
- Secret Rotation: Periodically rotate OAuth client secrets and JWT signing keys
- Scope Minimization: Request only the minimum OAuth scopes needed for your application
// Example of secure session setup
app.use(session({
secret: process.env.SESSION_SECRET, // Strong, random secret
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
sameSite: 'strict', // Protects against CSRF
maxAge: 2 * 60 * 60 * 1000 // 2 hours
},
store: new RedisStore({
client: redisClient,
ttl: 86400 // 24 hours (maximum session lifetime)
})
}));
// Implement session timeout/expiration check
app.use((req, res, next) => {
// Skip for non-authenticated requests
if (!req.isAuthenticated()) {
return next();
}
// Check session last active timestamp
const lastActive = req.session.lastActive || req.session.cookie.originalMaxAge;
const now = Date.now();
const idleTimeout = 30 * 60 * 1000; // 30 minutes
if (now - lastActive > idleTimeout) {
// Session has been idle too long, log out
req.logout(function(err) {
if (err) { return next(err); }
return res.redirect('/login?expired=1');
});
} else {
// Update last active timestamp
req.session.lastActive = now;
next();
}
});
Practice Activities
Activity 1: Basic Authentication System
Build a complete authentication system with Passport.js:
- Set up Express with Passport.js and express-session
- Implement the Local Strategy for username/password authentication
- Create registration, login, and logout functionality
- Add password hashing with bcrypt
- Implement protected routes that require authentication
- Create user profile page that displays user information
Focus on security best practices and proper error handling.
Activity 2: Multi-Provider OAuth
Extend your authentication system with OAuth providers:
- Register applications with at least two OAuth providers (Google, GitHub, etc.)
- Implement OAuth strategies for each provider
- Create a unified user account system that works with all providers
- Add account linking functionality to connect multiple providers to one account
- Create a user dashboard that shows all connected providers
- Implement provider disconnect functionality
Test with multiple user scenarios to ensure a smooth authentication experience.
Activity 3: API Authentication
Create a REST API with JWT authentication:
- Implement JWT Strategy for authenticating API requests
- Create endpoints for user registration and login that return JWT tokens
- Add token refresh functionality for expired tokens
- Implement role-based access control for API endpoints
- Create a simple frontend that demonstrates JWT authentication
- Add logout and token revocation functionality
Focus on creating a stateless, secure API authentication system.
Activity 4: Custom Authentication Strategy
Design and implement a custom Passport strategy:
- Define a unique authentication mechanism (API key, magic link, etc.)
- Create a custom Passport strategy that implements this mechanism
- Implement the necessary database models and storage
- Add routes that use your custom strategy
- Create an admin interface for managing authentication credentials
- Test your strategy with different scenarios
Document your strategy implementation and how it compares to built-in strategies.
Additional Resources
Summary
- Passport.js is a flexible authentication middleware for Node.js applications
- It supports numerous authentication strategies through a modular plugin system
- Core strategies include:
- Local Strategy for username/password authentication
- OAuth Strategies for social login (Google, Facebook, GitHub, etc.)
- JWT Strategy for stateless API authentication
- Custom strategies for specialized authentication needs
- Passport integrates seamlessly with Express.js through middleware
- It provides session management via serialization and deserialization
- Multiple strategies can be combined for comprehensive authentication
- Security best practices should be followed for robust authentication
With Passport.js, you can implement sophisticated authentication systems while keeping your code clean, modular, and maintainable.