From f2d459d39bb15029b786f1580a3b2fa45b610d1b Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Mon, 29 Jun 2026 15:29:52 +0100 Subject: [PATCH 1/2] fix(auth): reject token refresh and access validation for suspended or inactive users --- src/auth/auth.service.spec.ts | 14 +++++ src/auth/auth.service.ts | 6 +- src/auth/jwt.strategy.spec.ts | 103 ++++++++++++++++++++++++++++++++++ src/auth/jwt.strategy.ts | 8 ++- 4 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 src/auth/jwt.strategy.spec.ts diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index 7cf53035..95feb6b4 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -134,6 +134,20 @@ describe('AuthService', () => { expect(mockUserRepo.update).toHaveBeenCalledWith('user-1', { refreshToken: null }); }); + it('throws UnauthorizedException when the user status is SUSPENDED', async () => { + mockJwtService.verify.mockReturnValue(validDecoded); + mockUserRepo.findOneBy.mockResolvedValue(makeUser({ status: UserStatus.SUSPENDED })); + + await expect(service.refreshTokens('token')).rejects.toThrow(UnauthorizedException); + }); + + it('throws UnauthorizedException when the user status is INACTIVE', async () => { + mockJwtService.verify.mockReturnValue(validDecoded); + mockUserRepo.findOneBy.mockResolvedValue(makeUser({ status: UserStatus.INACTIVE })); + + await expect(service.refreshTokens('token')).rejects.toThrow(UnauthorizedException); + }); + it('issues new tokens when the refresh token is valid and not blacklisted', async () => { mockJwtService.verify.mockReturnValue(validDecoded); mockUserRepo.findOneBy.mockResolvedValue(makeUser()); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 3abeafaf..c7f75826 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -4,7 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { v4 as uuidv4 } from 'uuid'; import * as bcrypt from 'bcrypt'; -import { User } from '../users/entities/user.entity'; +import { User, UserStatus } from '../users/entities/user.entity'; import { TokenBlacklistService } from './services/token-blacklist.service'; @Injectable() @@ -51,6 +51,10 @@ export class AuthService { throw new UnauthorizedException('Access Denied'); } + if (user.status !== UserStatus.ACTIVE) { + throw new UnauthorizedException('User is not active'); + } + const refreshTokenMatches = await bcrypt.compare(refreshToken, user.refreshToken); if (!refreshTokenMatches) { throw new UnauthorizedException('Access Denied'); diff --git a/src/auth/jwt.strategy.spec.ts b/src/auth/jwt.strategy.spec.ts new file mode 100644 index 00000000..02d28d91 --- /dev/null +++ b/src/auth/jwt.strategy.spec.ts @@ -0,0 +1,103 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { UnauthorizedException } from '@nestjs/common'; +import { JwtStrategy, JwtPayload } from './jwt.strategy'; +import { User, UserStatus } from '../users/entities/user.entity'; + +describe('JwtStrategy', () => { + let strategy: JwtStrategy; + + const mockUserRepo = { + findOneBy: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + JwtStrategy, + { provide: getRepositoryToken(User), useValue: mockUserRepo }, + ], + }).compile(); + + strategy = module.get(JwtStrategy); + }); + + afterEach(() => jest.clearAllMocks()); + + it('should be defined', () => { + expect(strategy).toBeDefined(); + }); + + describe('validate', () => { + const payload: JwtPayload = { + sub: 'user-1', + email: 'test@example.com', + roles: [], + permissions: [], + }; + + const mockUser = { + id: 'user-1', + email: 'test@example.com', + status: UserStatus.ACTIVE, + }; + + const mockUserWithRolesAndPermissions = { + ...mockUser, + roles: [ + { + name: 'student', + permissions: [ + { resource: 'course', action: 'read' }, + ], + }, + ], + }; + + it('should successfully validate and return payload with roles and permissions if user is active', async () => { + mockUserRepo.findOneBy.mockResolvedValue(mockUser); + + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue(mockUserWithRolesAndPermissions), + }; + mockUserRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); + + const result = await strategy.validate(payload); + + expect(mockUserRepo.findOneBy).toHaveBeenCalledWith({ id: 'user-1' }); + expect(result).toEqual({ + sub: 'user-1', + email: 'test@example.com', + roles: ['student'], + permissions: ['course:read'], + }); + }); + + it('should throw UnauthorizedException if the user is suspended', async () => { + mockUserRepo.findOneBy.mockResolvedValue({ + ...mockUser, + status: UserStatus.SUSPENDED, + }); + + await expect(strategy.validate(payload)).rejects.toThrow(UnauthorizedException); + }); + + it('should throw UnauthorizedException if the user is inactive', async () => { + mockUserRepo.findOneBy.mockResolvedValue({ + ...mockUser, + status: UserStatus.INACTIVE, + }); + + await expect(strategy.validate(payload)).rejects.toThrow(UnauthorizedException); + }); + + it('should throw an error if the user is not found', async () => { + mockUserRepo.findOneBy.mockResolvedValue(null); + + await expect(strategy.validate(payload)).rejects.toThrow(Error); + }); + }); +}); diff --git a/src/auth/jwt.strategy.ts b/src/auth/jwt.strategy.ts index dae9b9e5..0f25ab40 100644 --- a/src/auth/jwt.strategy.ts +++ b/src/auth/jwt.strategy.ts @@ -1,9 +1,9 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { User } from '../users/entities/user.entity'; +import { User, UserStatus } from '../users/entities/user.entity'; export interface JwtPayload { sub: string; @@ -39,6 +39,10 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { throw new Error('User not found'); } + if (user.status !== UserStatus.ACTIVE) { + throw new UnauthorizedException('User is not active'); + } + // Fetch roles and permissions for the user const userWithRolesAndPermissions = await this.userRepository .createQueryBuilder('user') From e0e51f0ff002e4060478262697df6050fe711d59 Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Mon, 29 Jun 2026 16:09:42 +0100 Subject: [PATCH 2/2] fix(auth): fix jwt.strategy.spec.ts formatting and sync package-lock.json --- package-lock.json | 34 ++++++++++++++++++++++++++++++++++ src/auth/jwt.strategy.spec.ts | 9 ++------- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8509ef75..46c146ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4851,6 +4851,24 @@ } } }, + "node_modules/@nestjs/schematics/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nestjs/schematics/node_modules/jsonc-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", @@ -4881,6 +4899,22 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@nestjs/schematics/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nestjs/schematics/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", diff --git a/src/auth/jwt.strategy.spec.ts b/src/auth/jwt.strategy.spec.ts index 02d28d91..39effd29 100644 --- a/src/auth/jwt.strategy.spec.ts +++ b/src/auth/jwt.strategy.spec.ts @@ -14,10 +14,7 @@ describe('JwtStrategy', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [ - JwtStrategy, - { provide: getRepositoryToken(User), useValue: mockUserRepo }, - ], + providers: [JwtStrategy, { provide: getRepositoryToken(User), useValue: mockUserRepo }], }).compile(); strategy = module.get(JwtStrategy); @@ -48,9 +45,7 @@ describe('JwtStrategy', () => { roles: [ { name: 'student', - permissions: [ - { resource: 'course', action: 'read' }, - ], + permissions: [{ resource: 'course', action: 'read' }], }, ], };