DEV Community

Cover image for NestJS Authentication with OAuth2.0: Adding External Providers
Afonso Barracha
Afonso Barracha

Posted on • Edited on

NestJS Authentication with OAuth2.0: Adding External Providers

Series Intro

This series will cover the full implementation of OAuth2.0 Authentication in NestJS for the following types of APIs:

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:

OAuth Flow

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:

  1. Use adapter specific packages:
  2. Implement an NestJS External OAuth Provider, using dynamic modules;
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And for API calls we will use Axios, nest has its own Axios integration, so install them as well:

$ yarn add @nestjs/axios axios
Enter fullscreen mode Exit fullscreen mode

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',
}
Enter fullscreen mode Exit fullscreen mode

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];
}
Enter fullscreen mode Exit fullscreen mode

And import it on the UsersModule:

// ...
import { OAuthProviderEntity } from './entities/oauth-provider.entity';
// ...

@Module({
  imports: [MikroOrmModule.forFeature([UserEntity, OAuthProviderEntity])],
  // ...
})
export class UsersModule {}
Enter fullscreen mode Exit fullscreen mode

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>,
    // ...
  ) {}

  // ...
}
Enter fullscreen mode Exit fullscreen mode

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}))/;
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

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;
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

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);
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

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 } },
    );
  }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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),
    ]);
  }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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 };
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

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,
    );
    // ...
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

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,
    );
    // ...
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

OAuth2 Resource

To set up our external providers we will use a new REST resource named oauth2:

$ nest g res oauth2
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

Before implementing it we need to understand what we need from it. The OAuthClass will consist on three parts:

  1. Provider: the provider URLs and Client options, we abstract this with the AuthorizationCode from the simple-oauth2 package;
  2. Authorization URL: the URL with the parameters that we temporarily redirect our user to sign-in/up;
  3. 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 the redirect_uri (also known as the callback URL) and scope.

    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',
  };
}
Enter fullscreen mode Exit fullscreen mode

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]: '',
  };
}
Enter fullscreen mode Exit fullscreen mode

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'],
        };
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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];
  }
}
Enter fullscreen mode Exit fullscreen mode

Get Methods

Get methods are like parameters, but provided by methods with no arguments, this class will have 2:

  1. Get Data URL:

    // ...
    
    export class OAuthClass {
       // ...
    
      public get dataUrl(): string {
        return this.userDataUrl;
      }
    }
    
  2. 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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Add it and the url to the config.interface.ts:

import { IOAuth2 } from './oauth2.interface';

export interface IConfig {
  // ...
  readonly url: string;
  // ...
  readonly oauth2: IOAuth2;
}
Enter fullscreen mode Exit fullscreen mode

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(),
});
Enter fullscreen mode Exit fullscreen mode

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,
            },
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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,
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

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;

  // ...
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

Core Logic

The core logic is comprised of 4 methods:

  1. 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}`;
      }
    
      // ...
    }
    
  2. 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));
      }
    }
    
  3. 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;
      }
    
      // ...
    }
    
  4. 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);
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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');
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

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);
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
tugascript profile image
Afonso Barracha

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

Collapse
 
samihk profile image
Abdul Sami Haroon

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

Collapse
 
galentino profile image
Edric Galentino

You are hero bro. Thanks a lot

Collapse
 
Sloan, the sloth mascot
Comment deleted
Collapse
 
tugascript profile image
Afonso Barracha

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.