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
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:
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
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 standardizedOAuthUser
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;
}
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;
}
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;
}
}
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[] {}
}
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);
}
}
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
- Create an account with GCP here: https://cloud.google.com
- Start by navigating to your project API & Services > Credentials in GCP to create a new OAuth application.
- 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. - 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"
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();
}
}
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);
}
Add all the OAuth components to your OAuthModule
:
// oauth.module.ts - Add these to providers array
providers: [
// ... existing providers
OAuthService,
GoogleOAuthProvider,
OAuthProviderFactory,
],
controllers: [OAuthController],
Testing the Implementation
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)