OAuth2 and OpenID Connect Authentication in Node.js
Introduction
OAuth2 and OpenID Connect (OIDC) provide secure authentication and authorization. This guide covers implementing OAuth2 flows with multiple providers in Node.js.
Prerequisites
- Node.js >=16
- Understanding of OAuth2 flows
- Provider credentials (Google, GitHub, etc.)
Step 1: Install Dependencies
npm install express express-session passport
npm install passport-google-oauth20 passport-github2 passport-local
npm install jsonwebtoken jose node-oidc-provider
npm install redis connect-redis
Step 2: Basic OAuth2 Setup
Create config/oauth.js
:
module.exports = {
google: {
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: '/auth/google/callback',
},
github: {
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: '/auth/github/callback',
},
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: '24h',
},
session: {
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
},
};
Step 3: Passport Configuration
Create config/passport.js
:
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const GitHubStrategy = require('passport-github2').Strategy;
const LocalStrategy = require('passport-local').Strategy;
const jwt = require('jsonwebtoken');
const config = require('./oauth');
const User = require('../models/User');
// Serialize/Deserialize user for session
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser(async (id, done) => {
try {
const user = await User.findById(id);
done(null, user);
} catch (error) {
done(error, null);
}
});
// Google OAuth Strategy
passport.use(new GoogleStrategy({
clientID: config.google.clientID,
clientSecret: config.google.clientSecret,
callbackURL: config.google.callbackURL,
scope: ['profile', 'email'],
}, async (accessToken, refreshToken, profile, done) => {
try {
// Check if user exists
let user = await User.findOne({
$or: [
{ googleId: profile.id },
{ email: profile.emails[0].value }
]
});
if (user) {
// Update existing user
if (!user.googleId) {
user.googleId = profile.id;
user.providers.push('google');
await user.save();
}
return done(null, user);
}
// Create new user
user = new User({
googleId: profile.id,
email: profile.emails[0].value,
name: profile.displayName,
avatar: profile.photos[0].value,
providers: ['google'],
verified: true,
});
await user.save();
done(null, user);
} catch (error) {
done(error, null);
}
}));
// GitHub OAuth Strategy
passport.use(new GitHubStrategy({
clientID: config.github.clientID,
clientSecret: config.github.clientSecret,
callbackURL: config.github.callbackURL,
scope: ['user:email'],
}, async (accessToken, refreshToken, profile, done) => {
try {
let user = await User.findOne({
$or: [
{ githubId: profile.id },
{ email: profile.emails[0].value }
]
});
if (user) {
if (!user.githubId) {
user.githubId = profile.id;
user.providers.push('github');
await user.save();
}
return done(null, user);
}
user = new User({
githubId: profile.id,
email: profile.emails[0].value,
name: profile.displayName || profile.username,
avatar: profile.photos[0].value,
providers: ['github'],
verified: true,
});
await user.save();
done(null, user);
} catch (error) {
done(error, null);
}
}));
// Local Strategy for email/password
passport.use(new LocalStrategy({
usernameField: 'email',
passwordField: 'password',
}, async (email, password, done) => {
try {
const user = await User.findOne({ email });
if (!user) {
return done(null, false, { message: 'User not found' });
}
const isValidPassword = await user.comparePassword(password);
if (!isValidPassword) {
return done(null, false, { message: 'Invalid password' });
}
return done(null, user);
} catch (error) {
return done(error);
}
}));
module.exports = passport;
Step 4: JWT Token Management
Create utils/jwt.js
:
const jwt = require('jsonwebtoken');
const { SignJWT, jwtVerify } = require('jose');
const config = require('../config/oauth');
class TokenManager {
constructor() {
this.secret = new TextEncoder().encode(config.jwt.secret);
}
// Generate JWT token
async generateTokens(user) {
const payload = {
sub: user.id,
email: user.email,
name: user.name,
role: user.role,
};
const accessToken = await new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('15m')
.sign(this.secret);
const refreshToken = await new SignJWT({ sub: user.id })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(this.secret);
return { accessToken, refreshToken };
}
// Verify JWT token
async verifyToken(token) {
try {
const { payload } = await jwtVerify(token, this.secret);
return payload;
} catch (error) {
throw new Error('Invalid token');
}
}
// Generate PKCE code verifier and challenge
generatePKCE() {
const codeVerifier = this.base64URLEncode(crypto.randomBytes(32));
const codeChallenge = this.base64URLEncode(
crypto.createHash('sha256').update(codeVerifier).digest()
);
return {
codeVerifier,
codeChallenge,
codeChallengeMethod: 'S256',
};
}
base64URLEncode(str) {
return str.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
// Create authorization URL with state and PKCE
createAuthURL(provider, state, pkce) {
const params = new URLSearchParams({
client_id: config[provider].clientID,
redirect_uri: config[provider].callbackURL,
response_type: 'code',
state,
code_challenge: pkce.codeChallenge,
code_challenge_method: pkce.codeChallengeMethod,
});
if (provider === 'google') {
params.append('scope', 'openid profile email');
return `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
}
if (provider === 'github') {
params.append('scope', 'user:email');
return `https://github.com/login/oauth/authorize?${params}`;
}
}
}
module.exports = new TokenManager();
Step 5: Authentication Routes
Create routes/auth.js
:
const express = require('express');
const passport = require('../config/passport');
const tokenManager = require('../utils/jwt');
const crypto = require('crypto');
const router = express.Router();
// Store for PKCE codes and state (use Redis in production)
const authStore = new Map();
// Initiate Google OAuth
router.get('/google', (req, res, next) => {
const state = crypto.randomBytes(16).toString('hex');
const pkce = tokenManager.generatePKCE();
authStore.set(state, pkce);
const authURL = tokenManager.createAuthURL('google', state, pkce);
res.redirect(authURL);
});
// Google OAuth callback
router.get('/google/callback',
passport.authenticate('google', { session: false }),
async (req, res) => {
try {
const tokens = await tokenManager.generateTokens(req.user);
// Set secure HTTP-only cookies
res.cookie('accessToken', tokens.accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 15 * 60 * 1000, // 15 minutes
});
res.cookie('refreshToken', tokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
res.redirect('/dashboard');
} catch (error) {
res.status(500).json({ error: 'Authentication failed' });
}
}
);
// GitHub OAuth
router.get('/github', passport.authenticate('github', { scope: ['user:email'] }));
router.get('/github/callback',
passport.authenticate('github', { session: false }),
async (req, res) => {
try {
const tokens = await tokenManager.generateTokens(req.user);
res.cookie('accessToken', tokens.accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 15 * 60 * 1000,
});
res.redirect('/dashboard');
} catch (error) {
res.status(500).json({ error: 'Authentication failed' });
}
}
);
// Local login
router.post('/login', (req, res, next) => {
passport.authenticate('local', async (err, user, info) => {
if (err) return next(err);
if (!user) {
return res.status(401).json({ error: info.message });
}
try {
const tokens = await tokenManager.generateTokens(user);
res.cookie('accessToken', tokens.accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 15 * 60 * 1000,
});
res.json({ user: { id: user.id, email: user.email, name: user.name } });
} catch (error) {
res.status(500).json({ error: 'Authentication failed' });
}
})(req, res, next);
});
// Token refresh
router.post('/refresh', async (req, res) => {
try {
const { refreshToken } = req.cookies;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
const payload = await tokenManager.verifyToken(refreshToken);
const user = await User.findById(payload.sub);
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
const tokens = await tokenManager.generateTokens(user);
res.cookie('accessToken', tokens.accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 15 * 60 * 1000,
});
res.json({ message: 'Token refreshed' });
} catch (error) {
res.status(401).json({ error: 'Invalid refresh token' });
}
});
// Logout
router.post('/logout', (req, res) => {
res.clearCookie('accessToken');
res.clearCookie('refreshToken');
res.json({ message: 'Logged out successfully' });
});
// Get current user
router.get('/me', async (req, res) => {
try {
const { accessToken } = req.cookies;
if (!accessToken) {
return res.status(401).json({ error: 'No access token' });
}
const payload = await tokenManager.verifyToken(accessToken);
const user = await User.findById(payload.sub).select('-password');
res.json({ user });
} catch (error) {
res.status(401).json({ error: 'Invalid access token' });
}
});
module.exports = router;
Step 6: Authentication Middleware
Create middleware/auth.js
:
const tokenManager = require('../utils/jwt');
const User = require('../models/User');
// Authenticate JWT token
exports.authenticate = async (req, res, next) => {
try {
const token = req.cookies.accessToken ||
req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: 'No access token provided' });
}
const payload = await tokenManager.verifyToken(token);
const user = await User.findById(payload.sub).select('-password');
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
req.user = user;
next();
} catch (error) {
res.status(401).json({ error: 'Invalid or expired token' });
}
};
// Check if user has required role
exports.authorize = (roles) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
};
// Optional authentication (user may or may not be logged in)
exports.optionalAuth = async (req, res, next) => {
try {
const token = req.cookies.accessToken ||
req.headers.authorization?.replace('Bearer ', '');
if (token) {
const payload = await tokenManager.verifyToken(token);
const user = await User.findById(payload.sub).select('-password');
req.user = user;
}
} catch (error) {
// Ignore auth errors for optional auth
}
next();
};
Step 7: OIDC Provider Implementation
Create custom OIDC provider:
const { Provider } = require('oidc-provider');
const configuration = {
clients: [{
client_id: 'client-app',
client_secret: 'client-secret',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
redirect_uris: ['http://localhost:3001/auth/callback'],
scope: 'openid email profile',
}],
interactions: {
url(ctx, interaction) {
return `/interaction/${interaction.uid}`;
},
},
cookies: {
keys: [process.env.COOKIE_KEY],
},
claims: {
address: ['address'],
email: ['email', 'email_verified'],
phone: ['phone_number', 'phone_number_verified'],
profile: ['birthdate', 'family_name', 'gender', 'given_name', 'locale', 'middle_name', 'name',
'nickname', 'picture', 'preferred_username', 'profile', 'updated_at', 'website', 'zoneinfo'],
},
features: {
devInteractions: { enabled: false },
deviceFlow: { enabled: true },
introspection: { enabled: true },
revocation: { enabled: true },
},
findAccount: async (ctx, id) => {
const user = await User.findById(id);
if (!user) return undefined;
return {
accountId: id,
async claims(use, scope) {
return {
sub: id,
email: user.email,
name: user.name,
picture: user.avatar,
};
},
};
},
};
const oidc = new Provider('http://localhost:3000', configuration);
module.exports = oidc;
Summary
OAuth2 and OIDC provide secure authentication with multiple providers. Implement proper token management with JWT, use PKCE for security, and handle refresh tokens for seamless user experience across applications.