Series Intro
This series will cover the full implementation of OAuth2.0 Authentication in NestJS for the following types of APIs:
- Express REST API;
- Fastify REST API;
- Apollo GraphQL API.
And it is divided in 5 parts:
- Configuration and operations;
- Express Local OAuth REST API;
- Fastify Local OAuth REST API;
- Apollo Local OAuth GraphQL API;
- Adding External OAuth Providers to our API;
Lets start the fifth and last part of this series.
Tutorial Intro
On this tutorial we will add to the Fastify API from the third part the capability of signing in with external OAuth 2.0 providers such as Microsoft, Google, Facebook and GitHub.
This tutorial will be slightly more complex than the previous ones, as on the one hand, we need an implementation that works for both adapters (Fastify and Express), and on the other hand it should apply to the maximum number of external providers.
TLDR: if you do not have 45 minutes to read the article, the source code can be found on this repo. While you at it if you liked what you read consider buying me a coffee.
The Flow
The flow is a bit complex and requires access to the front-end, as we will need to be able to redirect back to it:
Note: Apple's flow is a bit different and will not be covered in this article.
Options
There are several ways to implement this flow in NestJS, for example this are my recommended 3 ways:
- Use adapter specific packages:
- Passport for Express;
- Fastify-OAuth2 for Fastify;
- Implement an NestJS External OAuth Provider, using dynamic modules;
- Implementing External OAuth Routes under a feature flags.
My preferred approach is the 3rd one, and that is the one we will implement on this tutorial.
Approach
We will:
- Allow for multiple types of authentication for each user;
- Only require confirmation when the user was registed through Local OAuth;
- Users can create a password for Local OAuth even if they registered with an external provider.
Since the setup is quite different, we will separate the Local OAuth and External OAuth into distinct modules.
This tutorial only covers the code and API logic, it will not show you how to register your app on Microsoft, Google, Facebook or GitHub. I will assume that you already know how to do that, if you do not, you can find how in each providers' official documentation.
Set up
Start by upgrading the library to the latest npm package versions:
$ yarn upgrade-interactive
NOTE: if you update Mikro-ORM to the latest version you will need to update the imports on the common.service.ts
to the EntityRepository
of the @mikro-orm/postgresql
package.
To facilitate our implementation we will use the simple-oauth2 library, so start by installing it:
$ yarn add simple-oauth2
$ yarn add -D @types/simple-oauth2
And for API calls we will use Axios, nest has its own Axios integration, so install them as well:
$ yarn add @nestjs/axios axios
Changes
There are several changes that we need to make on our previous API implementation.
User changes
Entities
OAuth Provider Entity
First we need an enum
with the possible providers, for this example we will allow 5 providers. Create the oauth-providers.enum.ts
on the enums
directory:
export enum OAuthProvidersEnum {
LOCAL = 'local',
MICROSOFT = 'microsoft',
GOOGLE = 'google',
FACEBOOK = 'facebook',
GITHUB = 'github',
}
Since we will allow multiple providers we need to create a OAuthProviderEntity
to keep track of the user's active providers. For simplicity we will not allow the user to manually to disconnect from the providers.
Create the oauth-provider.entity.ts
:
import {
Entity,
Enum,
ManyToOne,
PrimaryKeyType,
Property,
Unique,
} from '@mikro-orm/core';
import { IsEnum } from 'class-validator';
import { OAuthProvidersEnum } from '../enums/oauth-providers.enum';
import { IOAuthProvider } from '../interfaces/oauth-provider.interface';
import { UserEntity } from './user.entity';
@Entity({ tableName: 'oauth_providers' })
@Unique({ properties: ['provider', 'user'] })
export class OAuthProviderEntity implements IOAuthProvider {
@Enum({
items: () => OAuthProvidersEnum,
primary: true,
columnType: 'varchar',
length: 9,
})
@IsEnum(OAuthProvidersEnum)
public provider: OAuthProvidersEnum;
@ManyToOne({
entity: () => UserEntity,
primary: true,
onDelete: 'cascade',
})
public user: UserEntity;
@Property({ onCreate: () => new Date() })
public createdAt: Date = new Date();
@Property({ onUpdate: () => new Date() })
public updatedAt: Date = new Date();
[PrimaryKeyType]?: [OAuthProvidersEnum, number];
}
And import it on the UsersModule
:
// ...
import { OAuthProviderEntity } from './entities/oauth-provider.entity';
// ...
@Module({
imports: [MikroOrmModule.forFeature([UserEntity, OAuthProviderEntity])],
// ...
})
export class UsersModule {}
Finally inject it into the UserService
:
//...
import { OAuthProviderEntity } from './entities/oauth-provider.entity';
// ...
@Injectable()
export class UsersService {
constructor(
// ...
@InjectRepository(OAuthProviderEntity)
private readonly oauthProvidersRepository: EntityRepository<OAuthProviderEntity>,
// ...
) {}
// ...
}
User Entity
For the user entity we need to make some changes. Now the Password is not mandatory, so change the BCRYPT_HASH
regex into BCRYPT_HASH_OR_USET
:
export const BCRYPT_HASH_OR_UNSET =
/(UNSET|(\$2[abxy]?\$\d{1,2}\$[A-Za-z\d\./]{53}))/;
And add the one-to-many relation for the OAuthProviderEntity
:
import {
Collection,
// ...
OneToMany,
// ...
} from '@mikro-orm/core';
// ...
import { OAuthProviderEntity } from './oauth-provider.entity';
@Entity({ tableName: 'users' })
export class UserEntity implements IUser {
// ...
@OneToMany(() => OAuthProviderEntity, (oauth) => oauth.user)
public oauthProviders = new Collection<OAuthProviderEntity, UserEntity>(this);
}
Because of the external providers the users now can be confirmed automatically, so update the CredentialsEmbeddable
:
import { Embeddable, Property } from '@mikro-orm/core';
import dayjs from 'dayjs';
import { ICredentials } from '../interfaces/credentials.interface';
@Embeddable()
export class CredentialsEmbeddable implements ICredentials {
@Property({ default: 0 })
public version: number = 0;
@Property({ default: '' })
public lastPassword: string = '';
@Property({ default: dayjs().unix() })
public passwordUpdatedAt: number = dayjs().unix();
@Property({ default: dayjs().unix() })
public updatedAt: number = dayjs().unix();
constructor(isConfirmed = false) {
this.version = isConfirmed ? 1 : 0;
}
public updatePassword(password: string): void {
this.version++;
this.lastPassword = password;
const now = dayjs().unix();
this.passwordUpdatedAt = now;
this.updatedAt = now;
}
public updateVersion(): void {
this.version++;
this.updatedAt = dayjs().unix();
}
}
Service
When we create an user, we need to create an OAuthProvider
as well, therefore create the createOAuthProvider
private method:
// ...
import { OAuthProvidersEnum } from './enums/oauth-providers.enum';
@Injectable()
export class UsersService {
// ...
private async createOAuthProvider(
provider: OAuthProvidersEnum,
userId: number,
): Promise<OAuthProviderEntity> {
const oauthProvider = this.oauthProvidersRepository.create({
provider,
user: userId,
});
await this.commonService.saveEntity(
this.oauthProvidersRepository,
oauthProvider,
true,
);
return oauthProvider;
}
}
And update the create
public method, adding a new provider
argument, making the password
an optional argument, and confirming the user if the provider is not local:
// ...
@Injectable()
export class UsersService {
// ...
public async create(
provider: OAuthProvidersEnum,
email: string,
name: string,
password?: string,
): Promise<UserEntity> {
const isConfirmed = provider !== OAuthProvidersEnum.LOCAL;
const formattedEmail = email.toLowerCase();
await this.checkEmailUniqueness(formattedEmail);
const formattedName = this.commonService.formatName(name);
const user = this.usersRepository.create({
email: formattedEmail,
name: formattedName,
username: await this.generateUsername(formattedName),
password: isUndefined(password) ? 'UNSET' : await hash(password, 10),
confirmed: isConfirmed,
credentials: new CredentialsEmbeddable(isConfirmed),
});
await this.commonService.saveEntity(this.usersRepository, user, true);
await this.createOAuthProvider(provider, user.id);
return user;
}
}
To be able to accept external OAuth we need to be able to get or create a new user, so create the findOrCreate
public method:
// ...
@Injectable()
export class UsersService {
// ...
public async findOrCreate(
provider: OAuthProvidersEnum,
email: string,
name: string,
): Promise<UserEntity> {
const formattedEmail = email.toLowerCase();
const user = await this.usersRepository.findOne(
{
email: formattedEmail,
},
{
populate: ['oauthProviders'],
},
);
if (isUndefined(user) || isNull(user)) {
return this.create(provider, email, name);
}
if (
isUndefined(
user.oauthProviders.getItems().find((p) => p.provider === provider),
)
) {
await this.createOAuthProvider(provider, user.id);
}
return user;
}
// ...
}
Both Update and Reset Password have to take into account external providers, hence we need to refactor them a bit, start by extracting the password change logic into its own method:
// ...
@Injectable()
export class UsersService {
// ...
private async changePassword(
user: UserEntity,
password: string,
): Promise<UserEntity> {
user.credentials.updatePassword(user.password);
user.password = await hash(password, 10);
await this.commonService.saveEntity(this.usersRepository, user);
return user;
}
// ...
}
Next, update the updatePassword
and resetPassword
public methods:
// ...
@Injectable()
export class UsersService {
// ...
public async updatePassword(
userId: number,
newPassword: string,
password?: string,
): Promise<UserEntity> {
const user = await this.findOneById(userId);
if (user.password === 'UNSET') {
await this.createOAuthProvider(OAuthProvidersEnum.LOCAL, user.id);
} else {
if (isUndefined(password) || isNull(password)) {
throw new BadRequestException('Password is required');
}
if (!(await compare(password, user.password))) {
throw new BadRequestException('Wrong password');
}
if (await compare(newPassword, user.password)) {
throw new BadRequestException('New password must be different');
}
}
return await this.changePassword(user, newPassword);
}
public async resetPassword(
userId: number,
version: number,
password: string,
): Promise<UserEntity> {
const user = await this.findOneByCredentials(userId, version);
return await this.changePassword(user, password);
}
// ...
}
As a last update, just add a findOAuthProviders
so users can know how many providers they have active:
// ...
@Injectable()
export class UsersService {
// ...
public async findOAuthProviders(
userId: number,
): Promise<OAuthProviderEntity[]> {
return await this.oauthProvidersRepository.find(
{
user: userId,
},
{ orderBy: { provider: QueryOrder.ASC } },
);
}
// ...
}
Jwt Changes
There is just one minor change, as we need a private method from the AuthService
to be shared between it and the future Oauth2Service
for external providers.
Service
Move the AuthService
's generateAuthTokens
private method to the JwtService
so we can share its logic with the Oauth2Service
:
// ...
@Injectable()
export class JwtService {
// ...
public async generateAuthTokens(
user: IUser,
domain?: string,
tokenId?: string,
): Promise<[string, string]> {
return Promise.all([
this.generateToken(user, TokenTypeEnum.ACCESS, domain, tokenId),
this.generateToken(user, TokenTypeEnum.REFRESH, domain, tokenId),
]);
}
}
Auth Changes
Module
Move the ThrottlerModule
import into the AppModule
:
// ...
import { ThrottlerModule } from '@nestjs/throttler';
// ...
@Module({
imports: [
// ...
ThrottlerModule.forRootAsync({
imports: [ConfigModule],
useClass: ThrottlerConfig,
}),
// ...
],
// ...
})
export class AppModule {}
Service
Now that we have a public generateAuthTokens
remove the private version, and use the public version from the JwtService
:
// ...
@Injectable()
export class AuthService {
// ...
public async confirmEmail(
dto: ConfirmEmailDto,
domain?: string,
): Promise<IAuthResult> {
// ...
const [accessToken, refreshToken] =
await this.jwtService.generateAuthTokens(user, domain);
return { user, accessToken, refreshToken };
}
public async signIn(dto: SignInDto, domain?: string): Promise<IAuthResult> {
// ...
const [accessToken, refreshToken] =
await this.jwtService.generateAuthTokens(user, domain);
return { user, accessToken, refreshToken };
}
public async refreshTokenAccess(
refreshToken: string,
domain?: string,
): Promise<IAuthResult> {
// ...
const [accessToken, newRefreshToken] =
await this.jwtService.generateAuthTokens(user, domain, tokenId);
return { user, accessToken, refreshToken: newRefreshToken };
}
// ...
public async updatePassword(
userId: number,
dto: ChangePasswordDto,
domain?: string,
): Promise<IAuthResult> {
// ...
const [accessToken, refreshToken] =
await this.jwtService.generateAuthTokens(user, domain);
return { user, accessToken, refreshToken };
}
// ...
}
And the order of the inputs of the UsersService
's updatePassword
method change:
// ...
@Injectable()
export class AuthService {
// ...
public async updatePassword(
userId: number,
dto: ChangePasswordDto,
domain?: string,
): Promise<IAuthResult> {
// ...
const user = await this.usersService.updatePassword(
userId,
password1,
password,
);
// ...
}
// ...
}
On a last note, update the signUp
method to pass the OAuthProviderEnum.LOCAL
:
// ...
import { OAuthProvidersEnum } from '../users/enums/oauth-providers.enum';
// ...
@Injectable()
export class AuthService {
// ...
public async signUp(dto: SignUpDto, domain?: string): Promise<IMessage> {
// ...
const user = await this.usersService.create(
OAuthProvidersEnum.LOCAL,
email,
name,
password1,
);
// ...
}
// ...
}
OAuth2 Resource
To set up our external providers we will use a new REST resource named oauth2
:
$ nest g res oauth2
OAuth Class
Before starting working on the service and controller we need to create an external provider wrapper. It is just a class that encapsulates the OAuth provider logic, so create a classes
directory with an oauth.class.ts
file inside:
export class OAuthClass {}
Before implementing it we need to understand what we need from it. The OAuthClass
will consist on three parts:
-
Provider: the provider URLs and Client options, we abstract this with the
AuthorizationCode
from the simple-oauth2 package; - Authorization URL: the URL with the parameters that we temporarily redirect our user to sign-in/up;
- Data URL: the URL we use to get the user data when we get the external provider's access token.
Types
Create a new interfaces
directory with the following interfaces:
-
IAuthParams
: the parameters necessary for generating the Authorization URL, normally theredirect_uri
(also known as the callback URL) andscope
.
export interface IAuthParams { readonly redirect_uri: string; readonly scope: string | string[]; }
-
ICallbackQuery
: the query parameters that you receive on the (redirect_uri) callback URL.
export interface ICallbackQuery { readonly code: string; readonly state: string; }
-
IProvider
: the provider URLs (hosts) and relative paths.
export interface IProvider { readonly tokenHost: string; readonly tokenPath: string; readonly authorizeHost: string; readonly authorizePath: string; readonly refreshPath?: string; readonly revokePath?: string; }
-
User Responses: this one is not a single interface but many interfaces, one for each provider.
export interface IMicrosoftUser { readonly businessPhones: string[]; readonly displayName: string; readonly givenName: string; readonly jobTitle: string; readonly mail: string; readonly mobilePhone: string; readonly officeLocation: string; readonly preferredLanguage: string; readonly surname: string; readonly userPrincipalName: string; readonly id: string; } export interface IGoogleUser { readonly sub: string; readonly name: string; readonly given_name: string; readonly family_name: string; readonly picture: string; readonly email: string; readonly email_verified: boolean; readonly locale: string; readonly hd: string; } export interface IFacebookUser { readonly id: string; readonly name: string; readonly email: string; } interface IGitHubPlan { readonly name: string; readonly space: number; readonly private_repos: number; readonly collaborators: number; } export interface IGitHubUser { readonly login: string; readonly id: number; readonly node_id: string; readonly avatar_url: string; readonly gravatar_id: string; readonly url: string; readonly html_url: string; readonly followers_url: string; readonly following_url: string; readonly gists_url: string; readonly starred_url: string; readonly subscriptions_url: string; readonly organizations_url: string; readonly repos_url: string; readonly events_url: string; readonly received_events_url: string; readonly type: string; readonly site_admin: boolean; readonly name: string; readonly company: string; readonly blog: string; readonly location: string; readonly email: string; readonly hireable: boolean; readonly bio: string; readonly twitter_username: string; readonly public_repos: number; readonly public_gists: number; readonly followers: number; readonly following: number; readonly created_at: string; readonly updated_at: string; readonly private_gists: number; readonly total_private_repos: number; readonly owned_private_repos: number; readonly disk_usage: number; readonly collaborators: number; readonly two_factor_authentication: boolean; readonly plan: IGitHubPlan; }
Private Static Params
Back on the class, start adding the providers as static parameters, you can find most of them in the fastify-oauth2 repository.
First the providers:
import { IAuthParams } from '../interfaces/auth-params.interface';
import { IProvider } from '../interfaces/provider.interface';
export class OAuthClass {
private static readonly [OAuthProvidersEnum.MICROSOFT]: IProvider = {
authorizeHost: 'https://login.microsoftonline.com',
authorizePath: '/common/oauth2/v2.0/authorize',
tokenHost: 'https://login.microsoftonline.com',
tokenPath: '/common/oauth2/v2.0/token',
};
private static readonly [OAuthProvidersEnum.GOOGLE]: IProvider = {
authorizeHost: 'https://accounts.google.com',
authorizePath: '/o/oauth2/v2/auth',
tokenHost: 'https://www.googleapis.com',
tokenPath: '/oauth2/v4/token',
};
private static readonly [OAuthProvidersEnum.FACEBOOK]: IProvider = {
authorizeHost: 'https://facebook.com',
authorizePath: '/v9.0/dialog/oauth',
tokenHost: 'https://graph.facebook.com',
tokenPath: '/v9.0/oauth/access_token',
};
private static readonly [OAuthProvidersEnum.GITHUB]: IProvider = {
authorizeHost: 'https://github.com',
authorizePath: '/login/oauth/authorize',
tokenHost: 'https://github.com',
tokenPath: '/login/oauth/access_token',
};
}
And then the user data urls:
// ...
export class OAuthClass {
// ...
private static userDataUrls: Record<OAuthProvidersEnum, string> = {
[OAuthProvidersEnum.GOOGLE]:
'https://www.googleapis.com/oauth2/v3/userinfo',
[OAuthProvidersEnum.MICROSOFT]: 'https://graph.microsoft.com/v1.0/me',
[OAuthProvidersEnum.FACEBOOK]:
'https://graph.facebook.com/v16.0/me?fields=email,name',
[OAuthProvidersEnum.GITHUB]: 'https://api.github.com/user',
[OAuthProvidersEnum.LOCAL]: '',
};
}
We need a static method to generate the Authorization Parameters. It will take the provider and the API url as arguments, and return the params:
import { randomBytes } from 'crypto';
import { OAuthProvidersEnum } from '../../users/enums/oauth-providers.enum';
// ...
export class OAuthClass {
// ...
private static genAuthorization(
provider: OAuthProvidersEnum,
url: string,
): IAuthParams {
// generates the callback url given the provider
const redirect_uri = `${url}/api/auth/ext/${provider}/callback`;
switch (provider) {
case OAuthProvidersEnum.GOOGLE:
return {
redirect_uri,
scope: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
],
};
case OAuthProvidersEnum.MICROSOFT:
return {
redirect_uri,
scope: ['openid', 'profile', 'email'],
};
case OAuthProvidersEnum.FACEBOOK:
return {
redirect_uri,
scope: ['email', 'public_profile'],
};
case OAuthProvidersEnum.GITHUB:
return {
redirect_uri,
scope: ['user:email', 'read:user'],
};
}
}
}
Note: I made the scopes static for consistent, but they could have been passed as a parameter, this depends on your preference.
Private Non-static Params
This class will have three main non-static parameters:
- Code: a class to get the access token provided by the simple-oauth2 package;
- Authorization: the authorization params for the redirect URI;
- User Data URL: the URL to fetch the user data after getting the external provider's access token.
// ...
import { AuthorizationCode } from 'simple-oauth2';
import { IAuthParams } from '../interfaces/auth-params.interface';
import { IClient } from '../interfaces/client.interface';
// ...
export class OAuthClass {
// ...
private readonly code: AuthorizationCode;
private readonly authorization: IAuthParams;
private readonly userDataUrl: string;
constructor(
private readonly provider: OAuthProvidersEnum,
private readonly client: IClient,
private readonly url: string,
) {
if (provider === OAuthProvidersEnum.LOCAL) {
throw new Error('Invalid provider');
}
this.code = new AuthorizationCode({
client,
auth: OAuthClass[provider],
});
this.authorization = OAuthClass.genAuthorization(provider, url);
this.userDataUrl = OAuthClass.userDataUrls[provider];
}
}
Get Methods
Get methods are like parameters, but provided by methods with no arguments, this class will have 2:
-
Get Data URL:
// ... export class OAuthClass { // ... public get dataUrl(): string { return this.userDataUrl; } }
-
Get Authorization URL (this is provided by the
AuthorizationCode
from simple-oauth2) with a random state:
// ... export class OAuthClass { // ... public get authorizationUrl(): [string, string] { const state = randomBytes(16).toString('hex'); return [this.code.authorizeURL({ ...this.authorization, state }), state]; } }
Normal Methods
We need a method to get the access token from the external provided. Most of the underlying logic is already provided by the AuthorizationCode
class from the simple-oauth2 package.
// ...
export class OAuthClass {
// ...
public async getToken(code: string): Promise<string> {
const result = await this.code.getToken({
code,
redirect_uri: this.authorization.redirect_uri,
scope: this.authorization.scope,
});
return result.token.access_token as string;
}
}
Config
Create an interface with all our external providers, we will call it oauth2.interface.ts
:
import { IClient } from '../../oauth2/interfaces/client.interface';
export interface IOAuth2 {
readonly microsoft: IClient | null;
readonly google: IClient | null;
readonly facebook: IClient | null;
readonly github: IClient | null;
}
Add it and the url
to the config.interface.ts
:
import { IOAuth2 } from './oauth2.interface';
export interface IConfig {
// ...
readonly url: string;
// ...
readonly oauth2: IOAuth2;
}
Update the config schema with the new ENV variables:
import Joi from 'joi';
export const validationSchema = Joi.object({
// ...
URL: Joi.string().uri().required(),
// ...
MICROSOFT_CLIENT_ID: Joi.string().optional(),
MICROSOFT_CLIENT_SECRET: Joi.string().optional(),
GOOGLE_CLIENT_ID: Joi.string().optional(),
GOOGLE_CLIENT_SECRET: Joi.string().optional(),
FACEBOOK_CLIENT_ID: Joi.string().optional(),
FACEBOOK_CLIENT_SECRET: Joi.string().optional(),
GITHUB_CLIENT_ID: Joi.string().optional(),
GITHUB_CLIENT_SECRET: Joi.string().optional(),
});
And finally add them to the config
function:
// ...
export function config(): IConfig {
// ...
return {
// ...
url: process.env.URL,
// ...
oauth2: {
microsoft:
isUndefined(process.env.MICROSOFT_CLIENT_ID) ||
isUndefined(process.env.MICROSOFT_CLIENT_SECRET)
? null
: {
id: process.env.MICROSOFT_CLIENT_ID,
secret: process.env.MICROSOFT_CLIENT_SECRET,
},
google:
isUndefined(process.env.GOOGLE_CLIENT_ID) ||
isUndefined(process.env.GOOGLE_CLIENT_SECRET)
? null
: {
id: process.env.GOOGLE_CLIENT_ID,
secret: process.env.GOOGLE_CLIENT_SECRET,
},
facebook:
isUndefined(process.env.FACEBOOK_CLIENT_ID) ||
isUndefined(process.env.FACEBOOK_CLIENT_SECRET)
? null
: {
id: process.env.FACEBOOK_CLIENT_ID,
secret: process.env.FACEBOOK_CLIENT_SECRET,
},
github:
isUndefined(process.env.GITHUB_CLIENT_ID) ||
isUndefined(process.env.GITHUB_CLIENT_SECRET)
? null
: {
id: process.env.GITHUB_CLIENT_ID,
secret: process.env.GITHUB_CLIENT_SECRET,
},
},
};
}
Module
Start by importing the necessary dependencies, namely the UsersModule
, JwtModule
and HttpModule
, and export the Oauth2Service
:
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { JwtModule } from '../jwt/jwt.module';
import { UsersModule } from '../users/users.module';
import { Oauth2Controller } from './oauth2.controller';
import { Oauth2Service } from './oauth2.service';
@Module({
imports: [
UsersModule,
JwtModule,
HttpModule.register({
timeout: 5000,
maxRedirects: 5,
}),
],
controllers: [Oauth2Controller],
providers: [Oauth2Service],
})
export class Oauth2Module {}
Service
Inject all the necessary services:
import { Cache } from 'cache-manager';
import { HttpService } from '@nestjs/axios';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CommonService } from '../common/common.service';
import { JwtService } from '../jwt/jwt.service';
import { UsersService } from '../users/users.service';
@Injectable()
export class Oauth2Service {
constructor(
@Inject(CACHE_MANAGER)
private readonly cacheManager: Cache,
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly httpService: HttpService,
private readonly commonService: CommonService,
) {}
}
And the private parameters for each external provider:
// ...
import { OAuthProvidersEnum } from '../users/enums/oauth-providers.enum';
import { OAuthClass } from './classes/oauth.class';
@Injectable()
export class Oauth2Service {
private readonly [OAuthProvidersEnum.MICROSOFT]: OAuthClass | null;
private readonly [OAuthProvidersEnum.GOOGLE]: OAuthClass | null;
private readonly [OAuthProvidersEnum.FACEBOOK]: OAuthClass | null;
private readonly [OAuthProvidersEnum.GITHUB]: OAuthClass | null;
// ...
}
We will use a private static method to create these parameters:
// ...
import { isNull } from '../common/utils/validation.util';
@Injectable()
export class Oauth2Service {
// ...
constructor(
// ...
) {
const url = configService.get<string>('url');
this[OAuthProvidersEnum.MICROSOFT] = Oauth2Service.setOAuthClass(
OAuthProvidersEnum.MICROSOFT,
configService,
url,
);
this[OAuthProvidersEnum.GOOGLE] = Oauth2Service.setOAuthClass(
OAuthProvidersEnum.GOOGLE,
configService,
url,
);
this[OAuthProvidersEnum.FACEBOOK] = Oauth2Service.setOAuthClass(
OAuthProvidersEnum.FACEBOOK,
configService,
url,
);
this[OAuthProvidersEnum.GITHUB] = Oauth2Service.setOAuthClass(
OAuthProvidersEnum.GITHUB,
configService,
url,
);
}
private static setOAuthClass(
provider: OAuthProvidersEnum,
configService: ConfigService,
url: string,
): OAuthClass | null {
const client = configService.get<IClient | null>(
`oauth2.${provider.toLowerCase()}`,
);
if (isNull(client)) {
return null;
}
return new OAuthClass(provider, client, url);
}
}
And, since the provider can be null, we need to check everytime we call it. Thus, create a wrapper method to get the provider:
// ...
import {
// ...
NotFoundException,
} from '@nestjs/common';
// ...
@Injectable()
export class Oauth2Service {
// ...
private getOAuth(provider: OAuthProvidersEnum): OAuthClass {
const oauth = this[provider];
if (isNull(oauth)) {
throw new NotFoundException('Page not found');
}
return oauth;
}
}
Core Logic
The core logic is comprised of 4 methods:
-
Get Authorization URL: to redirect the user to the provider.
// ... @Injectable() export class Oauth2Service { // ... public async getAuthorizationUrl( provider: OAuthProvidersEnum, ): Promise<string> { const [url, state] = this.getOAuth(provider).authorizationUrl; // Cache state for 2 minutes await this.commonService.throwInternalError( this.cacheManager.set(this.getOAuthStateKey(state), provider, 120 * 1000), ); return url; } private getOAuthStateKey(state: string): string { return `oauth_state:${state}`; } // ... }
-
Get Access Token: get the provider's access token given the callback code and state.
// ... @Injectable() export class Oauth2Service { // ... private async getAccessToken( provider: OAuthProvidersEnum, code: string, state: string, ): Promise<string> { const oauth = this.getOAuth(provider); const stateProvider = await this.commonService.throwInternalError( this.cacheManager.get<OAuthProvidersEnum>(this.getOAuthStateKey(state)), ); if (!stateProvider || provider !== stateProvider) { throw new UnauthorizedException('Corrupted state'); } return await this.commonService.throwInternalError(oauth.getToken(code)); } }
-
Get User Data: this is dependent on the previous method, and uses its access token to get the user data from the provider.
// ... import { AxiosError } from 'axios'; import { catchError, firstValueFrom } from 'rxjs'; @Injectable() export class Oauth2Service { // ... public async getUserData<T extends Record<string, any>>( provider: OAuthProvidersEnum, cbQuery: ICallbackQuery, ): Promise<T> { const { code, state } = cbQuery; const accessToken = await this.getAccessToken(provider, code, state); const userData = await firstValueFrom( this.httpService .get<T>(this.getOAuth(provider).dataUrl, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, }, }) .pipe( catchError((error: AxiosError) => { throw new UnauthorizedException(error.response.data); }), ), ); return userData.data; } // ... }
-
Login: finds or creates a new user and generates the Auth tokens.
// ... @Injectable() export class Oauth2Service { // ... public async login( provider: OAuthProvidersEnum, email: string, name: string, ): Promise<[string, string]> { const user = await this.usersService.findOrCreate(provider, email, name); return this.jwtService.generateAuthTokens(user); } // ... }
Controller
Before start writing the controllers logic we need to understand the concept of a feature flag, if you ask ChatGPT, it will answer as follows:
A Feature Flag is a software development technique that allows developers to turn specific functionality or features of their application on or off, without deploying new code. It is also known as a feature toggle or feature switch.
A great article on how to implement them in NestJS can be found on the wanago's blog.
OAuth Flag Guard
To use feature flags we will use a mixin
guard that checks if provider is null or not:
import {
CanActivate,
ExecutionContext,
Injectable,
mixin,
NotFoundException,
Type,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { FastifyRequest } from 'fastify';
import { isNull } from '../../common/utils/validation.util';
import { OAuthProvidersEnum } from '../../users/enums/oauth-providers.enum';
import { IClient } from '../interfaces/client.interface';
export const OAuthFlagGuard = (
provider: OAuthProvidersEnum,
): Type<CanActivate> => {
@Injectable()
class OAuthFlagGuardClass implements CanActivate {
constructor(private readonly configService: ConfigService) {}
public canActivate(context: ExecutionContext): boolean {
const client = this.configService.get<IClient | null>(
`oauth2.${provider}`,
);
if (isNull(client)) {
const request = context.switchToHttp().getRequest<FastifyRequest>();
throw new NotFoundException(`Cannot ${request.method} ${request.url}`);
}
return true;
}
}
return mixin(OAuthFlagGuardClass);
};
DTOs
There is only necessity for one DTO, the CallbackQueryDto
:
import { IsString } from 'class-validator';
import { ICallbackQuery } from '../interfaces/callback-query.interface';
export abstract class CallbackQueryDto implements ICallbackQuery {
@IsString()
public code: string;
@IsString()
public state: string;
}
Controller Set-up
Add the same private parameters as the ones in the AuthController
, plus an url
parameter for the front-end url. Futhermore, inject the Oauth2Service
and the ConfigService
.
import {
Controller,
// ...
UseGuards,
} from '@nestjs/common';
import { FastifyThrottlerGuard } from '../auth/guards/fastify-throttler.guard';
import { ConfigService } from '@nestjs/config';
import { Oauth2Service } from './oauth2.service';
@ApiTags('Oauth2')
@Controller('api/auth/ext')
@UseGuards(FastifyThrottlerGuard)
export class Oauth2Controller {
private readonly url: string;
private readonly cookiePath = '/api/auth';
private readonly cookieName: string;
private readonly refreshTime: number;
private readonly testing: boolean;
constructor(
private readonly oauth2Service: Oauth2Service,
private readonly configService: ConfigService,
) {
this.url = `https://${this.configService.get<string>('domain')}`;
this.cookieName = this.configService.get<string>('REFRESH_COOKIE');
this.refreshTime = this.configService.get<number>('jwt.refresh.time');
this.testing = this.configService.get<boolean>('testing');
}
}
Controller main logic
The logic for all the routes will be the same, we will:
-
Temporary redirect the user to the external provider URL:
import { // ... HttpStatus, } from '@nestjs/common'; // ... import { FastifyReply } from 'fastify'; @ApiTags('Oauth2') @Controller('api/auth/ext') @UseGuards(FastifyThrottlerGuard) export class Oauth2Controller { // ... private async startRedirect( res: FastifyReply, provider: OAuthProvidersEnum, ): Promise<FastifyReply> { return res .status(HttpStatus.TEMPORARY_REDIRECT) .redirect(await this.oauth2Service.getAuthorizationUrl(provider)); } }
-
Recieve the response on our callback URL, and permanent redirect to the front-end with an access and refresh token pair:
// ... @ApiTags('Oauth2') @Controller('api/auth/ext') @UseGuards(FastifyThrottlerGuard) export class Oauth2Controller { // ... private async loginAndRedirect( res: FastifyReply, provider: OAuthProvidersEnum, email: string, name: string, ): Promise<FastifyReply> { const [accessToken, refreshToken] = await this.oauth2Service.login( provider, email, name, ); return res .cookie(this.cookieName, refreshToken, { secure: !this.testing, httpOnly: true, signed: true, path: this.cookiePath, expires: new Date(Date.now() + this.refreshTime * 1000), }) .status(HttpStatus.PERMANENT_REDIRECT) .redirect(`${this.url}/?access_token=${accessToken}`); } }
Controller routes
Create a redirect route for each provider:
import {
// ...
Query,
Res,
// ...
} from '@nestjs/common';
// ...
import { ApiNotFoundResponse, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Public } from '../auth/decorators/public.decorator';
import { CallbackQueryDto } from './dtos/callback-query.dto';
import { OAuthFlagGuard } from './guards/oauth-flag.guard';
// ...
@ApiTags('Oauth2')
@Controller('api/auth/ext')
@UseGuards(FastifyThrottlerGuard)
export class Oauth2Controller {
// ...
@Public()
@UseGuards(OAuthFlagGuard(OAuthProvidersEnum.MICROSOFT))
@Get('microsoft')
@ApiResponse({
description: 'Redirects to Microsoft OAuth2 login page',
status: HttpStatus.TEMPORARY_REDIRECT,
})
@ApiNotFoundResponse({
description: 'OAuth2 is not enabled for Microsoft',
})
public async microsoft(@Res() res: FastifyReply): Promise<FastifyReply> {
return this.startRedirect(res, OAuthProvidersEnum.MICROSOFT);
}
@Public()
@UseGuards(OAuthFlagGuard(OAuthProvidersEnum.GOOGLE))
@Get('google')
@ApiResponse({
description: 'Redirects to Google OAuth2 login page',
status: HttpStatus.TEMPORARY_REDIRECT,
})
@ApiNotFoundResponse({
description: 'OAuth2 is not enabled for Google',
})
public async google(@Res() res: FastifyReply): Promise<FastifyReply> {
return this.startRedirect(res, OAuthProvidersEnum.GOOGLE);
}
@Public()
@UseGuards(OAuthFlagGuard(OAuthProvidersEnum.FACEBOOK))
@Get('facebook')
@ApiResponse({
description: 'Redirects to Facebook OAuth2 login page',
status: HttpStatus.TEMPORARY_REDIRECT,
})
@ApiNotFoundResponse({
description: 'OAuth2 is not enabled for Facebook',
})
public async facebook(@Res() res: FastifyReply): Promise<FastifyReply> {
return this.startRedirect(res, OAuthProvidersEnum.FACEBOOK);
}
@Public()
@UseGuards(OAuthFlagGuard(OAuthProvidersEnum.GITHUB))
@Get('github')
@ApiResponse({
description: 'Redirects to GitHub OAuth2 login page',
status: HttpStatus.TEMPORARY_REDIRECT,
})
@ApiNotFoundResponse({
description: 'OAuth2 is not enabled for GitHub',
})
public async github(@Res() res: FastifyReply): Promise<FastifyReply> {
return this.startRedirect(res, OAuthProvidersEnum.GITHUB);
}
// ...
}
And a callback route for each provider:
// ...
import {
IFacebookUser,
IGitHubUser,
IGoogleUser,
IMicrosoftUser,
} from './interfaces/user-response.interface';
@ApiTags('Oauth2')
@Controller('api/auth/ext')
@UseGuards(FastifyThrottlerGuard)
export class Oauth2Controller {
// ...
@Public()
@UseGuards(OAuthFlagGuard(OAuthProvidersEnum.MICROSOFT))
@Get('microsoft/callback')
@ApiResponse({
description: 'Redirects to the frontend with the JWT token',
status: HttpStatus.PERMANENT_REDIRECT,
})
@ApiNotFoundResponse({
description: 'OAuth2 is not enabled for Microsoft',
})
public async microsoftCallback(
@Query() cbQuery: CallbackQueryDto,
@Res() res: FastifyReply,
): Promise<FastifyReply> {
const provider = OAuthProvidersEnum.MICROSOFT;
const { displayName, mail } =
await this.oauth2Service.getUserData<IMicrosoftUser>(provider, cbQuery);
return this.loginAndRedirect(res, provider, mail, displayName);
}
@Public()
@UseGuards(OAuthFlagGuard(OAuthProvidersEnum.GOOGLE))
@Get('google/callback')
@ApiResponse({
description: 'Redirects to the frontend with the JWT token',
status: HttpStatus.PERMANENT_REDIRECT,
})
@ApiNotFoundResponse({
description: 'OAuth2 is not enabled for Google',
})
public async googleCallback(
@Query() cbQuery: CallbackQueryDto,
@Res() res: FastifyReply,
): Promise<FastifyReply> {
const provider = OAuthProvidersEnum.GOOGLE;
const { name, email } = await this.oauth2Service.getUserData<IGoogleUser>(
provider,
cbQuery,
);
return this.loginAndRedirect(res, provider, email, name);
}
@Public()
@UseGuards(OAuthFlagGuard(OAuthProvidersEnum.FACEBOOK))
@Get('facebook/callback')
@ApiResponse({
description: 'Redirects to the frontend with the JWT token',
status: HttpStatus.PERMANENT_REDIRECT,
})
@ApiNotFoundResponse({
description: 'OAuth2 is not enabled for Facebook',
})
public async facebookCallback(
@Query() cbQuery: CallbackQueryDto,
@Res() res: FastifyReply,
): Promise<FastifyReply> {
const provider = OAuthProvidersEnum.FACEBOOK;
const { name, email } = await this.oauth2Service.getUserData<IFacebookUser>(
provider,
cbQuery,
);
return this.loginAndRedirect(res, provider, email, name);
}
@Public()
@UseGuards(OAuthFlagGuard(OAuthProvidersEnum.GITHUB))
@Get('github/callback')
@ApiResponse({
description: 'Redirects to the frontend with the JWT token',
status: HttpStatus.PERMANENT_REDIRECT,
})
@ApiNotFoundResponse({
description: 'OAuth2 is not enabled for GitHub',
})
public async githubCallback(
@Query() cbQuery: CallbackQueryDto,
@Res() res: FastifyReply,
): Promise<FastifyReply> {
const provider = OAuthProvidersEnum.GITHUB;
const { name, email } = await this.oauth2Service.getUserData<IGitHubUser>(
provider,
cbQuery,
);
return this.loginAndRedirect(res, provider, email, name);
}
// ...
}
Conclusion
This is the end of my OAuth 2.0 series with NestJS, with this series you have learnt how to create a production grade OAuth 2.0 microservice using NodeJS, with both local and external OAuth.
The full source code can be found on this repo.
About the Author
Hey there! I am Afonso Barracha, your go-to econometrician who found his way into the world of back-end development with a soft spot for GraphQL. If you enjoyed reading this article, why not show some love by buying me a coffee?
Lately, I have been diving deep into more advanced subjects. As a result, I have switched from sharing my thoughts every week to posting once or twice a month. This way, I can make sure to bring you the highest quality content possible.
Do not miss out on any of my latest articles – follow me here on dev, on twitter and LinkedIn to stay updated. I would be thrilled to welcome you to our ever-growing community! See you around!
Top comments (5)
Just noticed this morning there was a major bug on the tutorial, just fixed it after one year, sorry for the inconvenience.
I left the state static, but it actually should be cached and new on every request
thanks a ton, this have been super helpful, I've been looking to work on a POC with fastify + external authentication and this has done the job for me.
Also the code quality is super!
much love <3
You are hero bro. Thanks a lot
The way it is set up, if the user does not exists it will just create a new account with the external provider. You could add a third and forth endpoints for registration if you deem that as necessary.