DEV Community

Cover image for A Step-by-Step Guide to Implementing Multi-Provider SSO in NestJS with OAuth2
camillefauchier
camillefauchier

Posted on

A Step-by-Step Guide to Implementing Multi-Provider SSO in NestJS with OAuth2

Introduction

While basic JWT authentication with email/password is well-covered territory in NestJS, modern applications increasingly demand multiple authentication options. Users expect to sign in with Google, GitHub, Microsoft, or their traditional credentials - all seamlessly integrated into a single system.

This article is the continuation of my previous tutorial on JWT authentication in NestJS. I assume you have a working NestJS application with JWT authentication, Passport strategies, guards, and email/password login already implemented.

Starting from that foundation, we'll extend the system to support multiple OAuth2 providers while maintaining backward compatibility with traditional authentication. Users will be able to sign in with their preferred method, and the system will handle account linking automatically.

If you want to look at the full code you can check out my repo on Github.

What We'll Build

Starting from a basic JWT authentication system, we'll add:

  • A clean, extensible provider pattern for adding new OAuth2 services
  • Automatic account linking for users with matching emails
  • Support for OAuth-only users alongside traditional email/password users
  • An example with Google, GitHub and Microsoft integration

Dependencies installation

I've upgraded dependancies version since my first article

  • passport (0.7.0) is an authentication middleware for Node.js, widely used and extensible.
  • passport-local( ^1.0.0) is a Passport strategy for authentication with an email and password.
  • passport-jwt (4.0.1) is a Passport strategy for authentication with a JSON Web Token (JWT).
  • @nestjs/passport (10.0.3) is a Passport integration for NestJS.
  • @nestjs/jwt (10.2.0) is used to handle JWT tokens in NestJS. JWT (JSON Web Tokens)is a - compact and safe way to transmit information between parties as a JSON object. This information can be verified and trusted because it is digitally signed.
  • bcrypt (5.1.1)is a library for hashing passwords.

Add OAuth2 required dependencies

  • passport-google-oauth20
  • @types/passport-google-oauth20 (dev-dependency)
# OAuth2 providers
npm install passport-google-oauth20

# Type definitions
npm install --save-dev @types/passport-google-oauth20

Enter fullscreen mode Exit fullscreen mode

Database Schema Design : Add a OAuthAccountTable

**🚨Migration to Prisma*
For this multi-provider authentication system, I migrated from TypeORM to Prisma. TypeORM's decorator-heavy approach and clunky relationship management were slowing development. Prisma's schema-first design and auto-generated client dramatically simplified OAuth provider linking while eliminating the boilerplate code.*

Our authentication system requires adding the OAuthAccount entity with a one-to-many relationship with User Entity:

New database schema

User Model Changes:

  • password becomes nullable to support OAuth-only accounts
  • isOAuthUser flag distinguishes traditional vs social sign-ups
  • Maintains backward compatibility with existing email/password users

OAuth Account Entity:

  • Links multiple OAuth providers to a single user account
  • provider field stores the OAuth service name ('google', 'github', 'microsoft')
  • providerId stores the unique user identifier from each OAuth provider

This design enables flexible authentication scenarios:

  • Traditional email/password registration ✅
  • Pure OAuth sign-up (no password required) ✅
  • Account linking (users can connect multiple OAuth providers) ✅

OAuth2 Architecture Design

Now for the interesting part: OAuth2 integration. Rather than implementing each provider separately, you should create a unified architecture that makes adding new providers trivial. Start with creating a OAuthModule

Dependency Diagram

OAuth Provider Interface

Every OAuth2 provider will follow the same pattern. They need three methods to work.

  • authorize(): Generates the OAuth2 authorization URL where users are redirected to grant permissions. This URL includes the client ID, scopes, redirect URI, and other provider-specific parameters.
  • callback(code: string): Handles the OAuth2 callback after user authorization. Takes the authorization code from the query parameters, exchanges it for an access token, fetches user information from the provider's API, and returns a standardized OAuthUser object.
  • getProviderName(): Returns the provider identifier (e.g., 'google', 'github', 'microsoft') used for database storage and routing.

This interface standardizes the OAuth2 flow across all providers:

// src/auth/oauth/interfaces/oauth-provider.interface.ts
export interface OAuthProviderInterface {
  authorize(): string;
  callback(code: string): Promise<OAuthUser>;
  getProviderName(): string;
}
Enter fullscreen mode Exit fullscreen mode

OAuth Service

You need to create a new service to handle login and register with SSO.

First this service need a OAuthUser.

// src/auth/oauth/types/oauth-user.type.ts
export interface OAuthUser {
  id: string;
  email: string;
  firstName: string;
  lastName: string;
  picture?: string;
  emailVerified?: boolean;
}
Enter fullscreen mode Exit fullscreen mode

Let's create OAuthService. Don't forget to add it in OAuthModule providers.
This service handles:

  • New users signing up with OAuth2
  • Existing users linking additional OAuth2 accounts
  • Automatic account linking based on email addresses
// src/auth/oauth/oauth.service.ts
@Injectable()
export class OAuthService {
  constructor(
    private authService: AuthService,
    private oauthProviderFactory: OAuthProviderFactory,
    private usersService: UsersService,
  ) {}

  async getAuthorizationUrl(provider: OAuthProviderName): Promise<string> {
    return this.oauthProviderFactory.getProvider(provider).authorize();
  }

  async handleOAuthCallback(
    providerName: OAuthProviderName,
    code: string,
  ): Promise<AccessToken> {
    const provider = this.oauthProviderFactory.getProvider(providerName);
    const oauthUser = await provider.callback(code);
    const user = await this.handleOAuthLogin(providerName, oauthUser);
    return this.authService.login(user);
  }

  async handleOAuthLogin(
    provider: OAuthProviderName,
    oauthUser: OAuthUser,
  ): Promise<User> {
    const oauthAccount = await this.oauthAccountsService.findOneByProvider(
      provider,
      oauthUser.id,
    );

    if (oauthAccount) {
      return oauthAccount.user;
    }

    let user = await this.usersService.findOneByEmail(oauthUser.email);

    if (!user) {
      user = await this.usersService.createFromOAuthUser(oauthUser);
    }

    await this.oauthAccountsService.create(provider, oauthUser, user);

    return user;
  }
  }
Enter fullscreen mode Exit fullscreen mode

Provider Factory

The factory pattern is what makes this architecture truly scalable. It allows us to register multiple OAuth2 providers and retrieve them dynamically based on the route parameter:

// src/auth/oauth/providers/oauth-provider.factory.ts
@Injectable()
export class OAuthProviderFactory {
  private providers = new Map<OAuthProviderName, OAuthProviderInterface>();

  registerProvider(name: OAuthProviderName,provider: OAuthProviderInterface): void {}

  getProvider(name: OAuthProviderName): OAuthProviderInterface {}

  getSupportedProviders(): OAuthProviderName[] {}
}

Enter fullscreen mode Exit fullscreen mode

Key benefits:

  • Dynamic provider retrieval: Get any provider by name without hardcoding
  • Extensible: Adding a new provider requires only implementing the interface and registering it
  • Type-safe: TypeScript ensures only valid provider names are used
  • Centralized management: All providers are managed in one place

OAuth Controller

The controller provides universal endpoints that work with any OAuth2 provider. Notice how the same endpoints handle Google, GitHub, or any future provider:

// src/auth/oauth/oauth.controller.ts
@Public()
@Controller('oauth')
export class OAuthController {
  constructor(private oauthService: OAuthService) {}

  @Get(':provider')
  async authorize(
    @Param('provider') providerName: OAuthProviderName,
  ): Promise<{authorizationUrl: string}> {
    return {authorizationUrl: await this.oauthService.getAuthorizationUrl(providerName)};
  }

  @Get(':provider/callback')
  async callback(
    @Param('provider') providerName: OAuthProviderName,
    @Query('code') code: string,
    @Query('error') error: string,
  ): Promise<AccessToken> {
  return await this.oauthService.handleOAuthCallback(providerName, code);
  }
}
Enter fullscreen mode Exit fullscreen mode

Controller breakdown:

  • GET /auth/:provider: Universal authorization endpoint. Works with any provider name (google, github, …)
  • GET /auth/:provider/callback: Universal callback handler. The same logic processes callbacks from any OAuth2 provider
  • Provider-agnostic: The controller doesn't know or care about specific provider implementations

This is the power of the architecture - you get /auth/google, /auth/github, endpoints automatically once you register each provider!

Example: Let's implement Google OAuth2

Now let's see how simple it is to add our first provider to this architecture:

Environment Configuration

Create you OAuth application in GCP

  1. Create an account with GCP here: https://cloud.google.com
  2. Start by navigating to your project API & Services > Credentials in GCP to create a new OAuth application.
  3. Create the OAuth application. As part of the form, add to Authorized redirect URIs a redirect URL such as http://localhost:3000/auth/google/callback. Google will redirect the user to this url after they have authorized the application to access their Google account.
  4. Obtain the Client ID and Client Secret for your Google OAuth2 application and add it in your .env file below

Configure your environment variables

...
# Google OAuth Configuration
GOOGLE_CLIENT_ID="your_google_client_id"
GOOGLE_CLIENT_SECRET="your_google_client_secret"
GOOGLE_CALLBACK_URL="http://localhost:3000/auth/google/callback"
GOOGLE_AUTH_URL="https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL="https://www.googleapis.com/oauth2/v4/token"
GOOGLE_USER_INFO_URL="https://www.googleapis.com/oauth2/v2/userinfo"
GOOGLE_SCOPE="profile email"
Enter fullscreen mode Exit fullscreen mode

Google Provider Implementation

// src/auth/oauth/providers/google-oauth.provider.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OAuthProviderInterface } from '../interfaces/oauth-provider.interface';
import { OAuthUser } from '../types/oauth-user.type';
import { GoogleOAuthConfig } from '../types/google-oauth-config.type';

@Injectable()
export class GoogleOAuthProvider implements OAuthProviderInterface {
  private config: GoogleOAuthConfig;

  constructor(private configService: ConfigService) {
    this.config = {
      clientId: this.configService.get<string>('GOOGLE_CLIENT_ID'),
      clientSecret: this.configService.get<string>('GOOGLE_CLIENT_SECRET'),
      callbackURL: this.configService.get<string>('GOOGLE_CALLBACK_URL'),
      scope: this.configService.get<string>('GOOGLE_SCOPE').split(' '),
      authURL: this.configService.get<string>('GOOGLE_AUTH_URL'),
      tokenURL: this.configService.get<string>('GOOGLE_TOKEN_URL'),
    };
  }

  authorize(): string {
    const params = new URLSearchParams({
      client_id: this.config.clientId,
      redirect_uri: this.config.callbackURL,
      scope: this.config.scope.join(' '),
      response_type: 'code',
      access_type: 'offline',
      prompt: 'consent',
    });

    return `${this.config.authURL}?${params.toString()}`;
  }

  async callback(code: string): Promise<OAuthUser> {
    const tokenResponse = await this.exchangeCodeForToken(code);
    const userInfo = await this.getUserInfo(tokenResponse.access_token);

    return {
      id: userInfo.id,
      email: userInfo.email,
      firstName: userInfo.given_name ?? '',
      lastName: userInfo.family_name ?? '',
      picture: userInfo.picture,
      emailVerified: userInfo.verified_email,
    };
  }

  getProviderName(): string {
    return 'google';
  }

  private async exchangeCodeForToken(code: string): Promise<{access_token: string}> {
    const response = await fetch(this.config.tokenURL!, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: new URLSearchParams({
        client_id: this.config.clientId,
        client_secret: this.config.clientSecret,
        code,
        grant_type: 'authorization_code',
        redirect_uri: this.config.callbackURL,
      }),
    });

    if (!response.ok) {
      throw new Error('Failed to exchange code for token');
    }

    return response.json();
  }

  private async getUserInfo(accessToken: string) {
    const userInfoURL = this.configService.get<string>('GOOGLE_USER_INFO_URL')!;
    const response = await fetch(userInfoURL, {
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
    });

    if (!response.ok) {
      throw new Error('Failed to fetch user info');
    }

    return response.json();
  }
}

Enter fullscreen mode Exit fullscreen mode

Registering the Google Provider

Now you need to register the Google provider in the factory. Update your factory constructor to inject and register the Google provider:

// Update the OAuthProviderFactory constructor
constructor(private googleProvider: GoogleOAuthProvider) {
  this.registerProvider('google', this.googleProvider);
}

Enter fullscreen mode Exit fullscreen mode

Add all the OAuth components to your OAuthModule:

// oauth.module.ts - Add these to providers array
providers: [
  // ... existing providers
  OAuthService,
  GoogleOAuthProvider,
  OAuthProviderFactory,
],
controllers: [OAuthController],

Enter fullscreen mode Exit fullscreen mode

Testing the Implementation

Sequence diagram

What's Next?

This foundation supports adding additional OAuth2 providers with minimal effort. You can check the github repository for Microsoft Integration and Github integration.

The beauty of this architecture is its extensibility - adding a new OAuth2 provider requires only implementing the OAuthProviderInterface and registering it with the factory. No changes to controllers, services, or database schema needed.

This tutorial demonstrates an OAuth2 implementation that balances security, maintainability, and user experience. The complete source code is available on GitHub.

Top comments (0)