Skip to content
Go back

OAuth2 and OpenID Connect Authentication in Node.js

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

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.


Share this post on:

Previous Post
Microservices Design Patterns and Resilience
Next Post
CI/CD Pipeline with GitLab and Kubernetes