JWT

This recipe shows how to use NestJS JWT module to implement authentication with DryerJS.

Requirements

  • The system should have different user roles, like 'User' and 'Admin'.

  • Regular Users should not be able to grant themselves Admin privileges.

  • Users can update their own profile information.

  • Users must sign in to access certain features. The system should verify user identities and issue access tokens for signed-in users.

  • Only the creator of a post or an Admin can edit that post.

  • Unauthenticated (or public) users can only see publicly marked posts.

  • Regular users cannot see private posts created by other users but admins can.

Installation

$ npm install @nestjs/jwt --save

Implementation

import {
  CanActivate,
  ExecutionContext,
  Injectable,
  Logger,
  Module,
  OnModuleInit,
  UnauthorizedException,
  UseGuards,
  applyDecorators,
  createParamDecorator,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import {
  Args,
  Field,
  GqlExecutionContext,
  GraphQLModule,
  Mutation,
  PickType,
  Query,
  Resolver,
  registerEnumType,
  ObjectType,
  InputType,
} from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { MongooseModule } from '@nestjs/mongoose';
import {
  BelongsTo,
  CreateInputType,
  Definition,
  DryerModule,
  GraphQLObjectId,
  Id,
  ObjectId,
  OutputType,
  Property,
  Skip,
  BaseService,
  InjectBaseService,
  BeforeUpdateHook,
  BeforeReadFilterHook,
  BeforeWriteFilterHook,
  BeforeUpdateHookInput,
  BeforeWriteFilterHookInput,
  BeforeReadFilterHookInput,
} from 'dryerjs';
import { JwtModule, JwtService } from '@nestjs/jwt';
import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default';
 
enum UserRole {
  USER = 'USER',
  ADMIN = 'ADMIN',
}
 
registerEnumType(UserRole, { name: 'UserRole' });
 
export const Role = Reflector.createDecorator<UserRole>();
 
@Injectable()
export class RoleGuard implements CanActivate {
  constructor(
    private readonly jwtService: JwtService,
    private reflector: Reflector,
  ) {}
 
  async canActivate(executionContext: ExecutionContext): Promise<boolean> {
    const { req } = GqlExecutionContext.create(executionContext).getContext();
 
    const ctx = await this.jwtService
      .verifyAsync(req.header('Authorization')?.split(' ')?.[1])
      .catch(() => null);
 
    if (ctx?.id) {
      ctx.id = new ObjectId(ctx.id);
    }
 
    req.ctx = ctx;
 
    const role = this.reflector.get(Role, executionContext.getHandler());
    if (!role) return true;
    if (role === UserRole.ADMIN && ctx?.role !== UserRole.ADMIN) {
      throw new UnauthorizedException('AdminOnly');
    }
    if (
      role === UserRole.USER &&
      ![UserRole.ADMIN, UserRole.USER].includes(ctx?.role)
    ) {
      throw new UnauthorizedException('UserOnly');
    }
 
    return true;
  }
}
 
const UserOnly = () => {
  return applyDecorators(Role(UserRole.USER), UseGuards(RoleGuard));
};
 
const AdminOnly = () => {
  return applyDecorators(Role(UserRole.ADMIN), UseGuards(RoleGuard));
};
 
const PublicAccessWithRole = () => applyDecorators(UseGuards(RoleGuard));
 
@Definition()
export class User {
  @Id()
  id: ObjectId;
 
  @Property()
  email: string;
 
  @Property({ db: { type: String, enum: UserRole } })
  role: UserRole;
 
  @Property()
  name: string;
 
  @Property({ output: Skip })
  password: string;
}
 
@Definition()
export class Post {
  @Id()
  id: ObjectId;
 
  @Property()
  content: string;
 
  @Property({ nullable: true, create: { defaultValue: true } })
  isPublic: boolean;
 
  @BelongsTo(() => User, { from: 'userId' })
  user: User;
 
  @Property({ type: () => GraphQLObjectId })
  userId: ObjectId;
}
 
type Context = null | Pick<User, 'email' | 'id' | 'name' | 'role'>;
 
@Injectable()
class UserHook {
  @BeforeUpdateHook(() => User)
  async beforeUpdate({
    ctx,
    input,
  }: BeforeUpdateHookInput<User, Context>): Promise<void> {
    if (ctx.role === UserRole.USER && input.role === UserRole.ADMIN) {
      throw new UnauthorizedException('Cannot update role');
    }
    if (
      ctx.role === UserRole.USER &&
      input.id.toString() !== ctx.id.toString()
    ) {
      throw new UnauthorizedException('Cannot update other user');
    }
  }
}
 
@Injectable()
class PostHook {
  @BeforeWriteFilterHook(() => Post)
  async ensureNormalUserCanOnlyWriteToHimself({
    ctx,
    filter,
  }: BeforeWriteFilterHookInput<Post, Context>): Promise<void> {
    if (ctx?.role === UserRole.USER) {
      filter.userId = ctx.id;
    }
    // guest cannot write post so we don't need to handle it
  }
 
  @BeforeReadFilterHook(() => Post)
  async ensureGuestOrNormalUserCannotReadPrivatePostsOfOthers({
    ctx,
    filter,
  }: BeforeReadFilterHookInput<Post, Context>): Promise<void> {
    if (ctx === null) filter.isPublic = true;
    if (ctx?.role === UserRole.USER) {
      filter['$and'] = [
        {
          $or: [
            { userId: ctx.id },
            { userId: { $ne: ctx.id }, isPublic: true },
          ],
        },
      ];
    }
  }
}
 
export const Ctx = createParamDecorator(
  (_data: unknown, executionContext: ExecutionContext) => {
    const { req } = GqlExecutionContext.create(executionContext).getContext();
    if (req.ctx) return req.ctx as Context;
    return null;
  },
);
 
@ObjectType()
class AccessTokenResponse {
  @Field()
  accessToken: string;
}
 
@InputType()
class SignInInput extends PickType(CreateInputType(User), [
  'email',
  'password',
]) {
  @Field({ nullable: true })
  remember: boolean;
}
 
@Resolver()
export class AuthResolver {
  constructor(
    @InjectBaseService(User) public userService: BaseService<User, Context>,
    private readonly jwtService: JwtService,
  ) {}
 
  @Mutation(() => AccessTokenResponse)
  async signIn(
    @Args('input', {
      type: () => SignInInput,
    })
    input: Pick<User, 'email' | 'password'>,
  ) {
    const user = await this.userService.model.findOne({ email: input.email });
    if (user?.password !== input.password) {
      throw new UnauthorizedException('Invalid email or password');
    }
 
    const accessToken = await this.jwtService.signAsync({
      id: user.id.toString(),
      role: user.role,
      email: user.email,
      name: user.name,
    });
    return { accessToken };
  }
 
  @UserOnly()
  @Query(() => OutputType(User))
  async whoAmI(@Ctx() ctx: Context) {
    const user = await this.userService.findOne(ctx, { _id: ctx.id });
    if (user === null) {
      throw new UnauthorizedException();
    }
    return user;
  }
}
 
@Injectable()
export class SeederService implements OnModuleInit {
  constructor(
    @InjectBaseService(User) public userService: BaseService<User, Context>,
    @InjectBaseService(Post) public postService: BaseService<Post, Context>,
  ) {}
 
  async onModuleInit() {
    if ((await this.userService.model.countDocuments({})) !== 0) return;
    if (process.env.NODE_ENV !== 'test') {
      Logger.log('Run seeding...', SeederService.name);
    }
    const adminUserId = new ObjectId('000000000000000000000000');
    const normalUserId = new ObjectId('000000000000000000000001');
    const secondNormalUserId = new ObjectId('000000000000000000000002');
    const users = [
      {
        _id: adminUserId,
        email: '[email protected]',
        password: 'password',
        name: 'Admin@DryerJS',
        role: UserRole.ADMIN,
      },
      {
        _id: normalUserId,
        email: '[email protected]',
        password: 'password',
        name: 'User@DryerJS',
        role: UserRole.USER,
      },
      {
        _id: secondNormalUserId,
        email: '[email protected]',
        password: 'password',
        name: 'SecondUser@DryerJS',
        role: UserRole.USER,
      },
    ];
 
    const posts = [
      {
        _id: new ObjectId('000000000000000000000003'),
        content: 'Admin public announcement',
        isPublic: true,
        userId: adminUserId,
      },
      {
        _id: new ObjectId('000000000000000000000004'),
        content: 'Admin private note',
        isPublic: false,
        userId: adminUserId,
      },
      {
        _id: new ObjectId('000000000000000000000005'),
        content: 'User public note',
        isPublic: true,
        userId: normalUserId,
      },
      {
        _id: new ObjectId('000000000000000000000006'),
        content: 'User private note',
        isPublic: false,
        userId: normalUserId,
      },
      {
        _id: new ObjectId('000000000000000000000007'),
        content: 'Second user public note',
        isPublic: true,
        userId: secondNormalUserId,
      },
      {
        _id: new ObjectId('000000000000000000000008'),
        content: 'Second user private note',
        isPublic: false,
        userId: secondNormalUserId,
      },
    ];
    await this.userService.model.create(users);
    await this.postService.model.create(posts);
  }
}
 
@Module({
  imports: [
    GraphQLModule.forRoot<ApolloDriverConfig>({
      driver: ApolloDriver,
      autoSchemaFile: true,
      playground: false,
      plugins: [ApolloServerPluginLandingPageLocalDefault()],
    }),
    MongooseModule.forRoot(
      process.env.MONGO_URL || 'mongodb://127.0.0.1:27017/dryerjs-example',
    ),
    DryerModule.register({
      contextDecorator: Ctx,
      providers: [UserHook, PostHook],
      definitions: [
        {
          definition: User,
          allowedApis: ['findOne', 'paginate', 'remove', 'update'],
          decorators: {
            update: [UserOnly()],
            remove: [AdminOnly()],
          },
        },
        {
          definition: Post,
          decorators: {
            write: [UserOnly()],
            read: [PublicAccessWithRole()],
          },
        },
      ],
    }),
    JwtModule.register({
      global: true,
      secret: 'DO_NOT_TELL_ANYONE',
      signOptions: { expiresIn: '7d' },
    }),
  ],
  providers: [AuthResolver, SeederService, RoleGuard],
})
export class AppModule {}

Sample queries

Sign in

mutation {
  signIn(input: {
    email: "[email protected]"
    password: "password"
  }) {
    accessToken
  }
}

Paginate users

This is a public API. You don't need to set Authorization header.

query PaginateUsers {
  paginateUsers {
    docs {
      id
      role
      name
      email
    }
    totalDocs
  }
}

Find one user

This is a public API. You don't need to set Authorization header.

query User($userId: ObjectId!) {
  user(id: $userId) {
    email
    id
    name
    role
  }
}

Update user

User cannot update role field but ADMIN can.

mutation UpdateUser {
  updateUser(input: {
    id: "000000000000000000000000",
    role: "ADMIN"
  }) {
    email
    id
    name
    role
  }
}

Remove user

Only ADMIN can call this API.

mutation RemoveUser {
  removeUser(id: "000000000000000000000001") {
    success
  }
}

Paginate posts

ADMIN can see all posts. USER can only see public posts and their own posts. GUEST can only see public posts.

query PaginatedPost {
  paginatePosts {
    docs {
      id
      isPublic
      content
    }
  }
}

Find one post

[email protected] can see this post as he is ADMIN. [email protected] can see this post as he is the creator. [email protected] and GUEST cannot see this post because it is private and they are not the creator of this post.

query Post {
  post(id: "000000000000000000000006") {
    id
    content
  }
}

Remove post

ADMIN can remove any post. USER can only remove their own posts. GUEST cannot remove any post.

mutation RemovePost {
  removePost(id: "000000000000000000000003") {
    success
  }
}

Run the code

You can find the complete example with e2e test in here (opens in a new tab).