├── .env ├── .env.test ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── docker-compose.yml ├── jest.config.json ├── nest-cli.json ├── package.json ├── prisma ├── migrations │ ├── 20220106145018_init │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── scripts ├── migrate-test-db.sh └── restart-test-db.sh ├── src ├── app.module.ts ├── auth │ ├── auth.controller.ts │ ├── auth.module.ts │ ├── auth.service.ts │ ├── dto │ │ ├── auth.dto.ts │ │ └── index.ts │ ├── strategies │ │ ├── at.strategy.ts │ │ ├── index.ts │ │ └── rt.strategy.ts │ ├── test │ │ └── integration │ │ │ └── auth.service.spec.ts │ └── types │ │ ├── index.ts │ │ ├── jwtPayload.type.ts │ │ ├── jwtPayloadWithRt.type.ts │ │ └── tokens.type.ts ├── common │ ├── decorators │ │ ├── get-current-user-id.decorator.ts │ │ ├── get-current-user.decorator.ts │ │ ├── index.ts │ │ └── public.decorator.ts │ └── guards │ │ ├── at.guard.ts │ │ ├── index.ts │ │ └── rt.guard.ts ├── main.ts └── prisma │ ├── prisma.module.ts │ └── prisma.service.ts ├── test ├── app.e2e-spec.ts └── jest-e2e.json ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.env: -------------------------------------------------------------------------------- 1 | DATABASE_URL="postgresql://postgres:123@localhost:5432/nestjs?schema=public" 2 | AT_SECRET=at-secret 3 | RT_SECRET=rt-secret -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | DATABASE_URL="postgresql://postgres:123@localhost:5434/nestjs?schema=public" 2 | AT_SECRET=at-secret 3 | RT_SECRET=rt-secret -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/recommended', 10 | 'plugin:prettier/recommended', 11 | ], 12 | root: true, 13 | env: { 14 | node: true, 15 | jest: true, 16 | }, 17 | ignorePatterns: ['.eslintrc.js'], 18 | rules: { 19 | '@typescript-eslint/interface-name-prefix': 'off', 20 | '@typescript-eslint/explicit-function-return-type': 'off', 21 | '@typescript-eslint/explicit-module-boundary-types': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NestJs Jwt Authentication example with access token and refresh token - Integration and End-to-end tests included 2 | 3 | This is an example of how to implement an authentication system in NestJs using passport.js, and json web tokens (JWT). 4 | 5 | I've included integration tests in the auth module under "test" folder. 6 | 7 | The e2e tests on the other hand are in the root test folder. 8 | 9 | ```bash 10 | yarn test # run integration test 11 | yarn test:e2e # run e2e tests 12 | ``` 13 | 14 | The code reflects what was explained in the video: 15 | 16 | [![IMAGE ALT TEXT HERE](https://img.youtube.com/vi/uAKzFhE3rxU/0.jpg)](https://www.youtube.com/watch?v=uAKzFhE3rxU) 17 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | db: 4 | image: postgres:13 5 | ports: 6 | - 5432:5432 7 | environment: 8 | POSTGRES_USER: postgres 9 | POSTGRES_PASSWORD: 123 10 | POSTGRES_DB: nestjs 11 | test-db: 12 | image: postgres:13 13 | ports: 14 | - 5434:5432 15 | environment: 16 | POSTGRES_USER: postgres 17 | POSTGRES_PASSWORD: 123 18 | POSTGRES_DB: nestjs 19 | 20 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": "src", 4 | "testEnvironment": "node", 5 | "testRegex": ".spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-jwts", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "pretest": "sh scripts/restart-test-db.sh && sleep 1 && sh scripts/migrate-test-db.sh", 18 | "test": "dotenv -e .env.test -- jest -i --no-cache -c jest.config.json", 19 | "pretest:watch": "yarn pretest", 20 | "test:watch": "dotenv -e .env.test -- jest -i --watch -c jest.config.json", 21 | "test:cov": "dotenv -e .env.test -- jest -i --coverage -c jest.config.json", 22 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 23 | "pretest:e2e": "yarn pretest", 24 | "test:e2e": "dotenv -e .env.test -- jest -i --no-cache -c ./test/jest-e2e.json" 25 | }, 26 | "dependencies": { 27 | "@nestjs/common": "^8.0.0", 28 | "@nestjs/config": "^1.1.5", 29 | "@nestjs/core": "^8.0.0", 30 | "@nestjs/jwt": "^8.0.0", 31 | "@nestjs/passport": "^8.0.1", 32 | "@nestjs/platform-express": "^8.0.0", 33 | "@prisma/client": "^3.7.0", 34 | "argon2": "^0.28.3", 35 | "class-transformer": "^0.5.1", 36 | "class-validator": "^0.13.2", 37 | "passport": "^0.5.0", 38 | "passport-jwt": "^4.0.0", 39 | "reflect-metadata": "^0.1.13", 40 | "rimraf": "^3.0.2", 41 | "rxjs": "^7.2.0" 42 | }, 43 | "devDependencies": { 44 | "@nestjs/cli": "^8.0.0", 45 | "@nestjs/schematics": "^8.0.0", 46 | "@nestjs/testing": "^8.0.0", 47 | "@types/express": "^4.17.13", 48 | "@types/jest": "27.0.2", 49 | "@types/node": "^16.0.0", 50 | "@types/passport-jwt": "^3.0.6", 51 | "@types/supertest": "^2.0.11", 52 | "@typescript-eslint/eslint-plugin": "^5.0.0", 53 | "@typescript-eslint/parser": "^5.0.0", 54 | "dotenv-cli": "^4.1.1", 55 | "eslint": "^8.0.1", 56 | "eslint-config-prettier": "^8.3.0", 57 | "eslint-plugin-prettier": "^4.0.0", 58 | "jest": "^27.2.5", 59 | "prettier": "^2.3.2", 60 | "prisma": "^3.7.0", 61 | "source-map-support": "^0.5.20", 62 | "supertest": "^6.1.3", 63 | "ts-jest": "^27.0.3", 64 | "ts-loader": "^9.2.3", 65 | "ts-node": "^10.0.0", 66 | "tsconfig-paths": "^3.10.1", 67 | "typescript": "^4.3.5" 68 | }, 69 | "jest": { 70 | "moduleFileExtensions": [ 71 | "js", 72 | "json", 73 | "ts" 74 | ], 75 | "rootDir": "src", 76 | "testRegex": ".*\\.spec\\.ts$", 77 | "transform": { 78 | "^.+\\.(t|j)s$": "ts-jest" 79 | }, 80 | "collectCoverageFrom": [ 81 | "**/*.(t|j)s" 82 | ], 83 | "coverageDirectory": "../coverage", 84 | "testEnvironment": "node" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /prisma/migrations/20220106145018_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "users" ( 3 | "id" SERIAL NOT NULL, 4 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "updatedAt" TIMESTAMP(3) NOT NULL, 6 | "email" TEXT NOT NULL, 7 | "hash" TEXT NOT NULL, 8 | "hashedRt" TEXT, 9 | 10 | CONSTRAINT "users_pkey" PRIMARY KEY ("id") 11 | ); 12 | 13 | -- CreateIndex 14 | CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); 15 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "postgresql" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model User { 14 | id Int @id @default(autoincrement()) 15 | createdAt DateTime @default(now()) 16 | updatedAt DateTime @updatedAt 17 | 18 | email String @unique 19 | hash String 20 | hashedRt String? 21 | 22 | @@map("users") 23 | } 24 | -------------------------------------------------------------------------------- /scripts/migrate-test-db.sh: -------------------------------------------------------------------------------- 1 | npx dotenv -e .env.test -- prisma migrate reset --force -------------------------------------------------------------------------------- /scripts/restart-test-db.sh: -------------------------------------------------------------------------------- 1 | docker compose rm test-db -svf && docker compose up test-db -d -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { APP_GUARD } from '@nestjs/core'; 4 | import { AuthModule } from './auth/auth.module'; 5 | import { AtGuard } from './common/guards'; 6 | import { PrismaModule } from './prisma/prisma.module'; 7 | 8 | @Module({ 9 | imports: [ConfigModule.forRoot({ isGlobal: true }), AuthModule, PrismaModule], 10 | providers: [ 11 | { 12 | provide: APP_GUARD, 13 | useClass: AtGuard, 14 | }, 15 | ], 16 | }) 17 | export class AppModule {} 18 | -------------------------------------------------------------------------------- /src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | HttpCode, 5 | HttpStatus, 6 | Post, 7 | UseGuards, 8 | } from '@nestjs/common'; 9 | 10 | import { Public, GetCurrentUserId, GetCurrentUser } from '../common/decorators'; 11 | import { RtGuard } from '../common/guards'; 12 | import { AuthService } from './auth.service'; 13 | import { AuthDto } from './dto'; 14 | import { Tokens } from './types'; 15 | 16 | @Controller('auth') 17 | export class AuthController { 18 | constructor(private authService: AuthService) {} 19 | 20 | @Public() 21 | @Post('local/signup') 22 | @HttpCode(HttpStatus.CREATED) 23 | signupLocal(@Body() dto: AuthDto): Promise { 24 | return this.authService.signupLocal(dto); 25 | } 26 | 27 | @Public() 28 | @Post('local/signin') 29 | @HttpCode(HttpStatus.OK) 30 | signinLocal(@Body() dto: AuthDto): Promise { 31 | return this.authService.signinLocal(dto); 32 | } 33 | 34 | @Post('logout') 35 | @HttpCode(HttpStatus.OK) 36 | logout(@GetCurrentUserId() userId: number): Promise { 37 | return this.authService.logout(userId); 38 | } 39 | 40 | @Public() 41 | @UseGuards(RtGuard) 42 | @Post('refresh') 43 | @HttpCode(HttpStatus.OK) 44 | refreshTokens( 45 | @GetCurrentUserId() userId: number, 46 | @GetCurrentUser('refreshToken') refreshToken: string, 47 | ): Promise { 48 | return this.authService.refreshTokens(userId, refreshToken); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { JwtModule } from '@nestjs/jwt'; 3 | 4 | import { AuthController } from './auth.controller'; 5 | import { AuthService } from './auth.service'; 6 | import { AtStrategy, RtStrategy } from './strategies'; 7 | 8 | @Module({ 9 | imports: [JwtModule.register({})], 10 | controllers: [AuthController], 11 | providers: [AuthService, AtStrategy, RtStrategy], 12 | }) 13 | export class AuthModule {} 14 | -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { ForbiddenException, Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { JwtService } from '@nestjs/jwt'; 4 | import { PrismaClientKnownRequestError } from '@prisma/client/runtime'; 5 | import * as argon from 'argon2'; 6 | import { PrismaService } from '../prisma/prisma.service'; 7 | 8 | import { AuthDto } from './dto'; 9 | import { JwtPayload, Tokens } from './types'; 10 | 11 | @Injectable() 12 | export class AuthService { 13 | constructor( 14 | private prisma: PrismaService, 15 | private jwtService: JwtService, 16 | private config: ConfigService, 17 | ) {} 18 | 19 | async signupLocal(dto: AuthDto): Promise { 20 | const hash = await argon.hash(dto.password); 21 | 22 | const user = await this.prisma.user 23 | .create({ 24 | data: { 25 | email: dto.email, 26 | hash, 27 | }, 28 | }) 29 | .catch((error) => { 30 | if (error instanceof PrismaClientKnownRequestError) { 31 | if (error.code === 'P2002') { 32 | throw new ForbiddenException('Credentials incorrect'); 33 | } 34 | } 35 | throw error; 36 | }); 37 | 38 | const tokens = await this.getTokens(user.id, user.email); 39 | await this.updateRtHash(user.id, tokens.refresh_token); 40 | 41 | return tokens; 42 | } 43 | 44 | async signinLocal(dto: AuthDto): Promise { 45 | const user = await this.prisma.user.findUnique({ 46 | where: { 47 | email: dto.email, 48 | }, 49 | }); 50 | 51 | if (!user) throw new ForbiddenException('Access Denied'); 52 | 53 | const passwordMatches = await argon.verify(user.hash, dto.password); 54 | if (!passwordMatches) throw new ForbiddenException('Access Denied'); 55 | 56 | const tokens = await this.getTokens(user.id, user.email); 57 | await this.updateRtHash(user.id, tokens.refresh_token); 58 | 59 | return tokens; 60 | } 61 | 62 | async logout(userId: number): Promise { 63 | await this.prisma.user.updateMany({ 64 | where: { 65 | id: userId, 66 | hashedRt: { 67 | not: null, 68 | }, 69 | }, 70 | data: { 71 | hashedRt: null, 72 | }, 73 | }); 74 | return true; 75 | } 76 | 77 | async refreshTokens(userId: number, rt: string): Promise { 78 | const user = await this.prisma.user.findUnique({ 79 | where: { 80 | id: userId, 81 | }, 82 | }); 83 | if (!user || !user.hashedRt) throw new ForbiddenException('Access Denied'); 84 | 85 | const rtMatches = await argon.verify(user.hashedRt, rt); 86 | if (!rtMatches) throw new ForbiddenException('Access Denied'); 87 | 88 | const tokens = await this.getTokens(user.id, user.email); 89 | await this.updateRtHash(user.id, tokens.refresh_token); 90 | 91 | return tokens; 92 | } 93 | 94 | async updateRtHash(userId: number, rt: string): Promise { 95 | const hash = await argon.hash(rt); 96 | await this.prisma.user.update({ 97 | where: { 98 | id: userId, 99 | }, 100 | data: { 101 | hashedRt: hash, 102 | }, 103 | }); 104 | } 105 | 106 | async getTokens(userId: number, email: string): Promise { 107 | const jwtPayload: JwtPayload = { 108 | sub: userId, 109 | email: email, 110 | }; 111 | 112 | const [at, rt] = await Promise.all([ 113 | this.jwtService.signAsync(jwtPayload, { 114 | secret: this.config.get('AT_SECRET'), 115 | expiresIn: '15m', 116 | }), 117 | this.jwtService.signAsync(jwtPayload, { 118 | secret: this.config.get('RT_SECRET'), 119 | expiresIn: '7d', 120 | }), 121 | ]); 122 | 123 | return { 124 | access_token: at, 125 | refresh_token: rt, 126 | }; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/auth/dto/auth.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString } from 'class-validator'; 2 | 3 | export class AuthDto { 4 | @IsNotEmpty() 5 | @IsString() 6 | email: string; 7 | 8 | @IsNotEmpty() 9 | @IsString() 10 | password: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/auth/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth.dto'; 2 | -------------------------------------------------------------------------------- /src/auth/strategies/at.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { ExtractJwt, Strategy } from 'passport-jwt'; 5 | import { JwtPayload } from '../types'; 6 | 7 | @Injectable() 8 | export class AtStrategy extends PassportStrategy(Strategy, 'jwt') { 9 | constructor(config: ConfigService) { 10 | super({ 11 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 12 | secretOrKey: config.get('AT_SECRET'), 13 | }); 14 | } 15 | 16 | validate(payload: JwtPayload) { 17 | return payload; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/auth/strategies/index.ts: -------------------------------------------------------------------------------- 1 | export * from './at.strategy'; 2 | export * from './rt.strategy'; 3 | -------------------------------------------------------------------------------- /src/auth/strategies/rt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { PassportStrategy } from '@nestjs/passport'; 2 | import { ExtractJwt, Strategy } from 'passport-jwt'; 3 | import { Request } from 'express'; 4 | import { ForbiddenException, Injectable } from '@nestjs/common'; 5 | import { ConfigService } from '@nestjs/config'; 6 | import { JwtPayload, JwtPayloadWithRt } from '../types'; 7 | 8 | @Injectable() 9 | export class RtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') { 10 | constructor(config: ConfigService) { 11 | super({ 12 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 13 | secretOrKey: config.get('RT_SECRET'), 14 | passReqToCallback: true, 15 | }); 16 | } 17 | 18 | validate(req: Request, payload: JwtPayload): JwtPayloadWithRt { 19 | const refreshToken = req 20 | ?.get('authorization') 21 | ?.replace('Bearer', '') 22 | .trim(); 23 | 24 | if (!refreshToken) throw new ForbiddenException('Refresh token malformed'); 25 | 26 | return { 27 | ...payload, 28 | refreshToken, 29 | }; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/auth/test/integration/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { User } from '@prisma/client'; 3 | import { decode } from 'jsonwebtoken'; 4 | import { AppModule } from '../../../app.module'; 5 | import { PrismaService } from '../../../prisma/prisma.service'; 6 | import { AuthService } from '../../auth.service'; 7 | import { Tokens } from '../../types'; 8 | 9 | const user = { 10 | email: 'test@gmail.com', 11 | password: 'super-secret-password', 12 | }; 13 | 14 | describe('Auth Flow', () => { 15 | let prisma: PrismaService; 16 | let authService: AuthService; 17 | let moduleRef: TestingModule; 18 | 19 | beforeAll(async () => { 20 | moduleRef = await Test.createTestingModule({ 21 | imports: [AppModule], 22 | }).compile(); 23 | 24 | prisma = moduleRef.get(PrismaService); 25 | authService = moduleRef.get(AuthService); 26 | }); 27 | 28 | afterAll(async () => { 29 | await moduleRef.close(); 30 | }); 31 | 32 | describe('signup', () => { 33 | beforeAll(async () => { 34 | await prisma.cleanDatabase(); 35 | }); 36 | 37 | it('should signup', async () => { 38 | const tokens = await authService.signupLocal({ 39 | email: user.email, 40 | password: user.password, 41 | }); 42 | 43 | expect(tokens.access_token).toBeTruthy(); 44 | expect(tokens.refresh_token).toBeTruthy(); 45 | }); 46 | 47 | it('should throw on duplicate user signup', async () => { 48 | let tokens: Tokens | undefined; 49 | try { 50 | tokens = await authService.signupLocal({ 51 | email: user.email, 52 | password: user.password, 53 | }); 54 | } catch (error) { 55 | expect(error.status).toBe(403); 56 | } 57 | 58 | expect(tokens).toBeUndefined(); 59 | }); 60 | }); 61 | 62 | describe('signin', () => { 63 | beforeAll(async () => { 64 | await prisma.cleanDatabase(); 65 | }); 66 | it('should throw if no existing user', async () => { 67 | let tokens: Tokens | undefined; 68 | try { 69 | tokens = await authService.signinLocal({ 70 | email: user.email, 71 | password: user.password, 72 | }); 73 | } catch (error) { 74 | expect(error.status).toBe(403); 75 | } 76 | 77 | expect(tokens).toBeUndefined(); 78 | }); 79 | 80 | it('should login', async () => { 81 | await authService.signupLocal({ 82 | email: user.email, 83 | password: user.password, 84 | }); 85 | 86 | const tokens = await authService.signinLocal({ 87 | email: user.email, 88 | password: user.password, 89 | }); 90 | 91 | expect(tokens.access_token).toBeTruthy(); 92 | expect(tokens.refresh_token).toBeTruthy(); 93 | }); 94 | 95 | it('should throw if password incorrect', async () => { 96 | let tokens: Tokens | undefined; 97 | try { 98 | tokens = await authService.signinLocal({ 99 | email: user.email, 100 | password: user.password + 'a', 101 | }); 102 | } catch (error) { 103 | expect(error.status).toBe(403); 104 | } 105 | 106 | expect(tokens).toBeUndefined(); 107 | }); 108 | }); 109 | 110 | describe('logout', () => { 111 | beforeAll(async () => { 112 | await prisma.cleanDatabase(); 113 | }); 114 | 115 | it('should pass if call to non existent user', async () => { 116 | const result = await authService.logout(4); 117 | expect(result).toBeDefined(); 118 | }); 119 | 120 | it('should logout', async () => { 121 | await authService.signupLocal({ 122 | email: user.email, 123 | password: user.password, 124 | }); 125 | 126 | let userFromDb: User | null; 127 | 128 | userFromDb = await prisma.user.findFirst({ 129 | where: { 130 | email: user.email, 131 | }, 132 | }); 133 | expect(userFromDb?.hashedRt).toBeTruthy(); 134 | 135 | // logout 136 | await authService.logout(userFromDb!.id); 137 | 138 | userFromDb = await prisma.user.findFirst({ 139 | where: { 140 | email: user.email, 141 | }, 142 | }); 143 | 144 | expect(userFromDb?.hashedRt).toBeFalsy(); 145 | }); 146 | }); 147 | 148 | describe('refresh', () => { 149 | beforeAll(async () => { 150 | await prisma.cleanDatabase(); 151 | }); 152 | 153 | it('should throw if no existing user', async () => { 154 | let tokens: Tokens | undefined; 155 | try { 156 | tokens = await authService.refreshTokens(1, ''); 157 | } catch (error) { 158 | expect(error.status).toBe(403); 159 | } 160 | 161 | expect(tokens).toBeUndefined(); 162 | }); 163 | 164 | it('should throw if user logged out', async () => { 165 | // signup and save refresh token 166 | const _tokens = await authService.signupLocal({ 167 | email: user.email, 168 | password: user.password, 169 | }); 170 | 171 | const rt = _tokens.refresh_token; 172 | 173 | // get user id from refresh token 174 | // also possible to get using prisma like above 175 | // but since we have the rt already, why not just decoding it 176 | const decoded = decode(rt); 177 | const userId = Number(decoded?.sub); 178 | 179 | // logout the user so the hashedRt is set to null 180 | await authService.logout(userId); 181 | 182 | let tokens: Tokens | undefined; 183 | try { 184 | tokens = await authService.refreshTokens(userId, rt); 185 | } catch (error) { 186 | expect(error.status).toBe(403); 187 | } 188 | 189 | expect(tokens).toBeUndefined(); 190 | }); 191 | 192 | it('should throw if refresh token incorrect', async () => { 193 | await prisma.cleanDatabase(); 194 | 195 | const _tokens = await authService.signupLocal({ 196 | email: user.email, 197 | password: user.password, 198 | }); 199 | console.log({ 200 | _tokens, 201 | }); 202 | 203 | const rt = _tokens.refresh_token; 204 | 205 | const decoded = decode(rt); 206 | const userId = Number(decoded?.sub); 207 | 208 | let tokens: Tokens | undefined; 209 | try { 210 | tokens = await authService.refreshTokens(userId, rt + 'a'); 211 | } catch (error) { 212 | expect(error.status).toBe(403); 213 | } 214 | 215 | expect(tokens).toBeUndefined(); 216 | }); 217 | 218 | it('should refresh tokens', async () => { 219 | await prisma.cleanDatabase(); 220 | // log in the user again and save rt + at 221 | const _tokens = await authService.signupLocal({ 222 | email: user.email, 223 | password: user.password, 224 | }); 225 | 226 | const rt = _tokens.refresh_token; 227 | const at = _tokens.access_token; 228 | 229 | const decoded = decode(rt); 230 | const userId = Number(decoded?.sub); 231 | 232 | // since jwt uses seconds signature we need to wait for 1 second to have new jwts 233 | await new Promise((resolve, reject) => { 234 | setTimeout(() => { 235 | resolve(true); 236 | }, 1000); 237 | }); 238 | 239 | const tokens = await authService.refreshTokens(userId, rt); 240 | expect(tokens).toBeDefined(); 241 | 242 | // refreshed tokens should be different 243 | expect(tokens.access_token).not.toBe(at); 244 | expect(tokens.refresh_token).not.toBe(rt); 245 | }); 246 | }); 247 | }); 248 | -------------------------------------------------------------------------------- /src/auth/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './tokens.type'; 2 | export * from './jwtPayload.type'; 3 | export * from './jwtPayloadWithRt.type'; 4 | -------------------------------------------------------------------------------- /src/auth/types/jwtPayload.type.ts: -------------------------------------------------------------------------------- 1 | export type JwtPayload = { 2 | email: string; 3 | sub: number; 4 | }; 5 | -------------------------------------------------------------------------------- /src/auth/types/jwtPayloadWithRt.type.ts: -------------------------------------------------------------------------------- 1 | import { JwtPayload } from '.'; 2 | 3 | export type JwtPayloadWithRt = JwtPayload & { refreshToken: string }; 4 | -------------------------------------------------------------------------------- /src/auth/types/tokens.type.ts: -------------------------------------------------------------------------------- 1 | export type Tokens = { 2 | access_token: string; 3 | refresh_token: string; 4 | }; 5 | -------------------------------------------------------------------------------- /src/common/decorators/get-current-user-id.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | import { JwtPayload } from '../../auth/types'; 3 | 4 | export const GetCurrentUserId = createParamDecorator( 5 | (_: undefined, context: ExecutionContext): number => { 6 | const request = context.switchToHttp().getRequest(); 7 | const user = request.user as JwtPayload; 8 | return user.sub; 9 | }, 10 | ); 11 | -------------------------------------------------------------------------------- /src/common/decorators/get-current-user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | import { JwtPayloadWithRt } from '../../auth/types'; 3 | 4 | export const GetCurrentUser = createParamDecorator( 5 | (data: keyof JwtPayloadWithRt | undefined, context: ExecutionContext) => { 6 | const request = context.switchToHttp().getRequest(); 7 | if (!data) return request.user; 8 | return request.user[data]; 9 | }, 10 | ); 11 | -------------------------------------------------------------------------------- /src/common/decorators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-current-user.decorator'; 2 | export * from './get-current-user-id.decorator'; 3 | export * from './public.decorator'; 4 | -------------------------------------------------------------------------------- /src/common/decorators/public.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const Public = () => SetMetadata('isPublic', true); 4 | -------------------------------------------------------------------------------- /src/common/guards/at.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { AuthGuard } from '@nestjs/passport'; 4 | 5 | @Injectable() 6 | export class AtGuard extends AuthGuard('jwt') { 7 | constructor(private reflector: Reflector) { 8 | super(); 9 | } 10 | 11 | canActivate(context: ExecutionContext) { 12 | const isPublic = this.reflector.getAllAndOverride('isPublic', [ 13 | context.getHandler(), 14 | context.getClass(), 15 | ]); 16 | 17 | if (isPublic) return true; 18 | 19 | return super.canActivate(context); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/common/guards/index.ts: -------------------------------------------------------------------------------- 1 | export * from './at.guard'; 2 | export * from './rt.guard'; 3 | -------------------------------------------------------------------------------- /src/common/guards/rt.guard.ts: -------------------------------------------------------------------------------- 1 | import { AuthGuard } from '@nestjs/passport'; 2 | 3 | export class RtGuard extends AuthGuard('jwt-refresh') { 4 | constructor() { 5 | super(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { AppModule } from './app.module'; 4 | 5 | async function bootstrap() { 6 | const app = await NestFactory.create(AppModule); 7 | app.useGlobalPipes(new ValidationPipe()); 8 | await app.listen(3333); 9 | } 10 | bootstrap(); 11 | -------------------------------------------------------------------------------- /src/prisma/prisma.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { PrismaService } from './prisma.service'; 3 | 4 | @Global() 5 | @Module({ 6 | providers: [PrismaService], 7 | exports: [PrismaService], 8 | }) 9 | export class PrismaModule {} 10 | -------------------------------------------------------------------------------- /src/prisma/prisma.service.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '.prisma/client'; 2 | import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; 3 | import { ConfigService } from '@nestjs/config'; 4 | 5 | @Injectable() 6 | export class PrismaService 7 | extends PrismaClient 8 | implements OnModuleInit, OnModuleDestroy 9 | { 10 | constructor(config: ConfigService) { 11 | const url = config.get('DATABASE_URL'); 12 | 13 | super({ 14 | datasources: { 15 | db: { 16 | url, 17 | }, 18 | }, 19 | }); 20 | } 21 | 22 | async onModuleInit() { 23 | await this.$connect(); 24 | } 25 | 26 | async onModuleDestroy() { 27 | await this.$disconnect(); 28 | } 29 | 30 | async cleanDatabase() { 31 | if (process.env.NODE_ENV === 'production') return; 32 | 33 | // teardown logic 34 | return Promise.all([this.user.deleteMany()]); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication, ValidationPipe } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | import { PrismaService } from '../src/prisma/prisma.service'; 6 | import { AuthDto } from '../src/auth/dto'; 7 | import { Tokens } from '../src/auth/types'; 8 | 9 | describe('AppController (e2e)', () => { 10 | let app: INestApplication; 11 | let prisma: PrismaService; 12 | 13 | beforeAll(async () => { 14 | const moduleFixture: TestingModule = await Test.createTestingModule({ 15 | imports: [AppModule], 16 | }).compile(); 17 | 18 | app = moduleFixture.createNestApplication(); 19 | app.useGlobalPipes(new ValidationPipe()); 20 | await app.init(); 21 | 22 | prisma = app.get(PrismaService); 23 | await prisma.cleanDatabase(); 24 | }); 25 | 26 | afterAll(async () => { 27 | await app.close(); 28 | }); 29 | 30 | describe('Auth', () => { 31 | const dto: AuthDto = { 32 | email: 'test@gmail.com', 33 | password: 'super-secret-password', 34 | }; 35 | 36 | let tokens: Tokens; 37 | 38 | it('should signup', () => { 39 | return request(app.getHttpServer()) 40 | .post('/auth/local/signup') 41 | .send(dto) 42 | .expect(201) 43 | .expect(({ body }: { body: Tokens }) => { 44 | expect(body.access_token).toBeTruthy(); 45 | expect(body.refresh_token).toBeTruthy(); 46 | }); 47 | }); 48 | it('should signin', () => { 49 | return request(app.getHttpServer()) 50 | .post('/auth/local/signin') 51 | .send(dto) 52 | .expect(200) 53 | .expect(({ body }: { body: Tokens }) => { 54 | expect(body.access_token).toBeTruthy(); 55 | expect(body.refresh_token).toBeTruthy(); 56 | 57 | tokens = body; 58 | }); 59 | }); 60 | 61 | it('should refresh tokens', async () => { 62 | // wait for 1 second 63 | await new Promise((resolve, reject) => { 64 | setTimeout(() => { 65 | resolve(true); 66 | }, 1000); 67 | }); 68 | 69 | return request(app.getHttpServer()) 70 | .post('/auth/refresh') 71 | .auth(tokens.refresh_token, { 72 | type: 'bearer', 73 | }) 74 | .expect(200) 75 | .expect(({ body }: { body: Tokens }) => { 76 | expect(body.access_token).toBeTruthy(); 77 | expect(body.refresh_token).toBeTruthy(); 78 | 79 | expect(body.refresh_token).not.toBe(tokens.access_token); 80 | expect(body.refresh_token).not.toBe(tokens.refresh_token); 81 | }); 82 | }); 83 | 84 | it('should logout', () => { 85 | return request(app.getHttpServer()) 86 | .post('/auth/logout') 87 | .auth(tokens.access_token, { 88 | type: 'bearer', 89 | }) 90 | .expect(200); 91 | }); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": true, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | --------------------------------------------------------------------------------