guide authentication domain - wwwsolutions/nestjs-starter-kit GitHub Wiki

Domains | Business logic

Example from Prisma-GraphQL integration

Authentication Domain

Each library has a single responsibility. Authentication is utilized via JWT token.

Install Dependencies

npm install @nestjs/passport passport passport-jwt passport-local

npm i -D @types/passport-jwt @types/passport-local

Structure

jwt configuration

ADD ENV VARIABLES
# apps/api/.env.development

##### JWT CONFIGURATION ################################################################################

### REQUIRED

JWT_SECRET='My$deepest$Secret#123456789'

### OPTIONAL
# OVERRIDE DEFAULT VALUES FROM `libs/api/config/features/src/lib/validation.schema.ts`

# JWT_SIGN_OPTIONS_EXPIRES_IN=3600
ADD VALIDATION
# libs/api/config/features/src/lib/validation.schema.ts

  /* --------------------------------------------------------------
  JWT
  api/config/features/src/lib/configs/jwt.configuration.ts
  --------------------------------------------------------------- */

  // REQUIRED
  JWT_SECRET: joiPassword
    .string()
    .required()
    .minOfSpecialCharacters(2)
    .minOfLowercase(2)
    .minOfUppercase(2)
    .minOfNumeric(2)
    .noWhiteSpaces()
    .description('JWT secret'),

  // OPTIONAL
  JWT_SIGN_OPTIONS_EXPIRES_IN: Joi.number()
    .positive()
    .default(3600)
    .description('JWT sign options expires'),
CREATE CONFIGURATION FILE
// libs/api/config/features/src/lib/configs/jwt.configuration.ts

import { Inject } from '@nestjs/common';
import { ConfigType, registerAs } from '@nestjs/config';
import { JwtModuleOptions } from '@nestjs/jwt';

export const jwtConfiguration = registerAs('jwt', () => ({
  secret: process.env.JWT_SECRET,
  expiresIn: Number(process.env.JWT_SIGN_OPTIONS_EXPIRES_IN),

  get options(): JwtModuleOptions {
    return {
      secret: process.env.JWT_SECRET,
      signOptions: {
        expiresIn: Number(process.env.JWT_SIGN_OPTIONS_EXPIRES_IN),
      },
    };
  },
}));

export type JwtConfiguration = ConfigType<typeof jwtConfiguration>;

export const InjectJwtConfig = () => Inject(jwtConfiguration.KEY);
REGISTER CONFIGURATION
// libs/api/config/features/src/lib/registrations/business-logic-configs.registration.ts

import { jwtConfiguration } from '../configs/jwt.configuration';

export const businessLogicConfigs: any = [jwtConfiguration];
EXPOSE CONFIGURATION
// libs/api/config/features/src/index.ts

export * from './lib/configs/jwt.configuration';

api-domain-authentication-feature

This is base domain library which holds:

  • decorators
  • guards
  • resolvers
  • strategies
GENERATE
nx generate @nrwl/nest:library --name=feature --directory=api/domain/authentication --buildable --standaloneConfig --strict --tags=type:feature,scope:api --no-interactive
EDIT FILES
// libs/api/domain/authentication/feature/src/lib/api-domain-authentication-feature.module.ts

import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';

import {
  JwtConfiguration,
  jwtConfiguration,
} from '@wwwsolutions/api/config/features';

import { ApiDataAccessCoreModule } from '@wwwsolutions/api/data-access/core';

import { AuthenticationService } from '@wwwsolutions/api/domain/authentication/data-access';

import { UsersService } from '@wwwsolutions/api/domain/users/data-access';

import { AuthResolver } from './resolvers/auth.resolver';

import { JwtStrategy } from './strategies/jwt.strategy';

import { GqlAuthGuard } from './guards/gql-auth.guard';

@Module({
  imports: [
    ApiDataAccessCoreModule,
    JwtModule.registerAsync({
      useFactory: async (jwtConfiguration: JwtConfiguration) => ({
        // api/config/features/src/lib/configs/jwt.configuration.ts
        ...jwtConfiguration.options,
      }),
      inject: [jwtConfiguration.KEY],
    }),
  ],
  providers: [
    AuthenticationService,
    UsersService,
    JwtStrategy,
    AuthResolver,
    GqlAuthGuard,
  ],
})
export class ApiDomainAuthenticationFeatureModule {}
CREATE FILES
// libs/api/domain/authentication/feature/src/lib/decorators/ctx-user.decorator.ts

import { createParamDecorator } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';

export const CtxUser = createParamDecorator(
  (data, ctx) => GqlExecutionContext.create(ctx).getContext().req.user
);
// libs/api/domain/authentication/feature/src/lib/guards/gql-auth.guard.ts

import { ExecutionContext, Injectable } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class GqlAuthGuard extends AuthGuard('jwt') {
  getRequest(context: ExecutionContext) {
    const ctx = GqlExecutionContext.create(context);
    return ctx.getContext().req;
  }
}
// libs/api/domain/authentication/feature/src/lib/resolvers/auth.resolver.ts

import { Args, Mutation, Resolver } from '@nestjs/graphql';

import { AuthenticationService } from '@wwwsolutions/api/domain/authentication/data-access';

import {
  UserToken,
  UserLoginInput,
  UserRegisterInput,
} from '@wwwsolutions/api/domain/users/data-access';

@Resolver()
export class AuthResolver {
  constructor(private readonly auth: AuthenticationService) {}

  // [PUBLIC API]

  // LOGIN USER
  @Mutation(() => UserToken)
  async login(
    @Args({ name: 'userLoginInput', type: () => UserLoginInput })
    userLoginInput: UserLoginInput
  ): Promise<UserToken> {
    return this.auth.login(userLoginInput);
  }

  // REGISTER USER
  @Mutation(() => UserToken)
  async register(
    @Args({ name: 'userRegisterInput', type: () => UserRegisterInput })
    userRegisterInput: UserRegisterInput
  ): Promise<UserToken> {
    return this.auth.register(userRegisterInput);
  }
}
// libs/api/domain/authentication/feature/src/lib/strategies/jwt.strategy.ts

import { UnauthorizedException, Injectable, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';

import {
  AuthenticationService,
  Jwt,
} from '@wwwsolutions/api/domain/authentication/data-access';

import {
  InjectJwtConfig,
  JwtConfiguration,
} from '@wwwsolutions/api/config/features';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  private readonly logger = new Logger(JwtStrategy.name);

  constructor(
    @InjectJwtConfig()
    readonly jwtConfiguration: JwtConfiguration,

    private readonly auth: AuthenticationService
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      // api/config/features/src/lib/configs/jwt.configuration.ts
      secretOrKey: jwtConfiguration.secret,
    });
  }

  async validate(payload: Jwt) {
    // this.logger.log(payload);

    const validated = await this.auth.validateUser(payload.userId);

    if (!validated) {
      throw new UnauthorizedException();
    }

    // this.logger.log(validated);

    return validated;
  }
}
// libs/api/domain/authentication/feature/src/lib/strategies/local.strategy.ts

import { UnauthorizedException, Injectable, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';

import { AuthenticationService } from '@wwwsolutions/api/domain/authentication/data-access';

import { User } from '@prisma/client';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  private readonly logger = new Logger(LocalStrategy.name);

  constructor(private readonly auth: AuthenticationService) {
    super({
      usernameField: 'email',
    });
  }

  async validate(email: string, password: string): Promise<User> {
    const validated = await this.auth.validateUserByEmailAndPassword(
      email,
      password
    );

    if (!validated) {
      throw new UnauthorizedException();
    }

    this.logger.log(validated);

    return validated;
  }
}
EXPOSE PUBLIC API
// libs/api/domain/authentication/feature/src/index.ts

export * from './lib/api-domain-authentication-feature.module';
export * from './lib/decorators/ctx-user.decorator';
export * from './lib/guards/gql-auth.guard';

api-domain-authentication-data-access

Contains domain's data layer and exposes:

  • ApiGraphqlAuthenticationDataAccessModule
  • AuthenticationService
  • models
GENERATE
nx generate @nrwl/nest:library --name=data-access --directory=api/domain/authentication --buildable --standaloneConfig --strict --tags=type:data-access,scope:api --no-interactive
EDIT FILES
// libs/api/domain/authentication/data-access/src/lib/api-domain-authentication-data-access.module.ts

import { Module } from '@nestjs/common';

import { ApiDataAccessCoreModule } from '@wwwsolutions/api/data-access/core';

import { UsersService } from '@wwwsolutions/api/domain/users/data-access';

import { AuthenticationService } from './authentication.service';

@Module({
  imports: [ApiDataAccessCoreModule],
  providers: [AuthenticationService, UsersService],
  exports: [AuthenticationService],
})
export class ApiDomainAuthenticationDataAccessModule {}
CREATE FILES
// libs/api/domain/authentication/data-access/src/lib/authentication.service.ts

import {
  BadRequestException,
  Injectable,
  Logger,
  NotFoundException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';

import { PrismaDataService } from '@wwwsolutions/api/data-access/core';

import { AuthenticationUtils } from '@wwwsolutions/shared/utils';

import { User } from '@wwwsolutions/api/data-access/models';

import {
  UserToken,
  UserLoginInput,
  UserRegisterInput,
} from '@wwwsolutions/api/domain/users/data-access';

import { UsersService } from '@wwwsolutions/api/domain/users/data-access';

import { Jwt } from './models/jwt.model';

@Injectable()
export class AuthenticationService {
  private readonly logger = new Logger(AuthenticationService.name);

  constructor(
    private readonly data: PrismaDataService,
    private readonly usersService: UsersService,
    private readonly jwtService: JwtService
  ) {}

  // LOGIN USER
  async login({ email, password }: UserLoginInput): Promise<UserToken> {
    // find user
    const found = await this.data.findUserByEmail(email);

    if (!found) {
      throw new NotFoundException(`User with email ${email} does not exist`);
    }

    // validate password
    const isPasswordValid = await AuthenticationUtils.validate(
      password,
      found.password
    );

    if (!isPasswordValid) {
      throw new Error(`Invalid password`);
    }

    // sign token
    const signedToken = this.signToken(found.id);

    // compose return object
    const userToken = { user: found, token: signedToken };

    return userToken;
  }

  // REGISTER USER
  async register({ email, password }: UserRegisterInput): Promise<UserToken> {
    // make sure email is unique, check database
    const found = await this.data.findUserByEmail(email);

    if (found) {
      throw new BadRequestException(`Cannot register with email ${email}`);
    }

    // hash password
    const hashedPassword = await AuthenticationUtils.hash(password);

    // create and return new user with hashed pwd
    const created = await this.data.createUser({
      data: { email, password: hashedPassword },
    });

    // sign token
    const signedToken = this.signToken(created.id);

    // compose return object
    const userToken = { user: created, token: signedToken };

    return userToken;
  }

  // [HELPER METHODS]

  // VALIDATE USER
  async validateUser(userId: number): Promise<User | null> {
    // TODO: check if user credentials has expired

    const validated = await this.usersService.user({ where: { id: userId } });

    return validated;
  }

  async validateUserByEmailAndPassword(
    email: string,
    password: string
  ): Promise<User | null> {
    throw new Error('Method not implemented.');
  }

  // SIGN TOKEN
  signToken(userId: number): string {
    const payload: Jwt = {
      userId,
      // TODO: extend token with metadata. example: role or isAdmin
    };

    const signed = this.jwtService.sign(payload);

    return signed;
  }
}
// libs/api/domain/authentication/data-access/src/lib/models/jwt.model.ts

export class Jwt {
  userId!: number;
}
EXPOSE PUBLIC API
// libs/api/domain/authentication/data-access/src/index.ts

export * from './lib/api-domain-authentication-data-access.module';
export * from './lib/authentication.service';
export * from './lib/models/jwt.model;

Register domain

Add feature module name to the domains array.

// libs/api/core/src/lib/registrations/domains.registration.ts 

import { ApiDomainAuthenticationFeatureModule } from '@wwwsolutions/api/domain/authentication/feature';

export const domains: NestImportsType = [ApiDomainAuthenticationFeatureModule];

Implement PROTECTED|RESTRICTED API

Utilize guards.

// libs/api/domain/users/feature/src/lib/users.resolver.ts

...
import { UseGuards } from '@nestjs/common';
import { GqlAuthGuard } from '@wwwsolutions/api/domain/authentication/feature';
...

...
// [PROTECTED/RESTRICTED API]
@UseGuards(GqlAuthGuard) // implement guard on class level | implement guard on method level
@Resolver()
...
⚠️ **GitHub.com Fallback** ⚠️