From fb004c24d6a00af4556b2e766e0b1c3a2b578a11 Mon Sep 17 00:00:00 2001 From: Nafiu Ishaq <63783380+nafiuishaaq@users.noreply.github.com> Date: Sun, 21 Jun 2026 04:49:46 +0000 Subject: [PATCH 1/7] implemented the refresh token --- src/modules/users/user.entity.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/modules/users/user.entity.ts b/src/modules/users/user.entity.ts index 4c1d393..ae7b953 100644 --- a/src/modules/users/user.entity.ts +++ b/src/modules/users/user.entity.ts @@ -25,6 +25,12 @@ export class User { @Column({ type: 'varchar', length: 500, nullable: true }) suspensionReason: string | null; + @Column({ type: 'varchar', length: 500, nullable: true }) + refreshToken: string | null; + + @Column({ type: 'datetime', nullable: true }) + refreshTokenExpiry: Date | null; + @CreateDateColumn({ type: 'datetime' }) createdAt: Date; From de17ebe038ef159af2e4aa80e98265f7155f0523 Mon Sep 17 00:00:00 2001 From: Nafiu Ishaq <63783380+nafiuishaaq@users.noreply.github.com> Date: Sun, 21 Jun 2026 04:50:29 +0000 Subject: [PATCH 2/7] implemented the refresh token --- src/modules/auth/dto/refresh.dto.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/modules/auth/dto/refresh.dto.ts diff --git a/src/modules/auth/dto/refresh.dto.ts b/src/modules/auth/dto/refresh.dto.ts new file mode 100644 index 0000000..6a09b15 --- /dev/null +++ b/src/modules/auth/dto/refresh.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty } from 'class-validator'; + +export class RefreshDto { + @ApiProperty({ description: 'Refresh token' }) + @IsString() + @IsNotEmpty() + refreshToken: string; +} From 616b225abfc2364b987ca293f0f8bdd63b77cc36 Mon Sep 17 00:00:00 2001 From: Nafiu Ishaq <63783380+nafiuishaaq@users.noreply.github.com> Date: Sun, 21 Jun 2026 04:50:35 +0000 Subject: [PATCH 3/7] implemented the refresh token --- src/modules/auth/auth.service.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index cab0c52..5d9b33c 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -3,14 +3,25 @@ import { JwtService } from '@nestjs/jwt'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Wallet } from '../wallet/wallet.entity'; +import { User } from '../users/user.entity'; import { Keypair } from '@stellar/stellar-sdk'; import { LoginDto } from './dto/login.dto'; +import { randomBytes } from 'crypto'; + +export interface AuthTokens { + accessToken: string; + refreshToken: string; + publicKey: string; + userId: string; +} @Injectable() export class AuthService { constructor( @InjectRepository(Wallet) private walletRepository: Repository, + @InjectRepository(User) + private userRepository: Repository, private jwtService: JwtService, ) {} From 6e396acdebb99138e30b3005c71a1b50a2fac534 Mon Sep 17 00:00:00 2001 From: Nafiu Ishaq <63783380+nafiuishaaq@users.noreply.github.com> Date: Sun, 21 Jun 2026 04:50:42 +0000 Subject: [PATCH 4/7] implemented the refresh token --- src/modules/auth/auth.service.ts | 57 ++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 5d9b33c..004f1d0 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -41,17 +41,70 @@ export class AuthService { throw new UnauthorizedException('Wallet not found'); } - // Generate JWT token + const user = await this.userRepository.findOne({ where: { id: wallet.userId } }); + if (!user) { + throw new UnauthorizedException('User not found'); + } + const payload = { publicKey: wallet.publicKey, sub: wallet.userId }; - const accessToken = this.jwtService.sign(payload); + const accessToken = this.generateAccessToken(payload); + const { token: refreshToken, expiry: refreshTokenExpiry } = this.createRefreshToken(); + + await this.userRepository.update(user.id, { + refreshToken, + refreshTokenExpiry, + }); return { accessToken, + refreshToken, publicKey: wallet.publicKey, userId: wallet.userId, }; } + async refresh(refreshToken: string) { + if (!refreshToken) { + throw new UnauthorizedException('Refresh token is required'); + } + + const user = await this.userRepository.findOne({ where: { refreshToken } }); + if (!user || !user.refreshTokenExpiry || user.refreshTokenExpiry.getTime() <= Date.now()) { + throw new UnauthorizedException('Invalid or expired refresh token'); + } + + const wallet = await this.walletRepository.findOne({ where: { userId: user.id } }); + if (!wallet) { + throw new UnauthorizedException('Wallet not found'); + } + + const payload = { publicKey: wallet.publicKey, sub: user.id }; + const accessToken = this.generateAccessToken(payload); + const { token: newRefreshToken, expiry: refreshTokenExpiry } = this.createRefreshToken(); + + await this.userRepository.update(user.id, { + refreshToken: newRefreshToken, + refreshTokenExpiry, + }); + + return { + accessToken, + refreshToken: newRefreshToken, + publicKey: wallet.publicKey, + userId: user.id, + }; + } + + private generateAccessToken(payload: { publicKey: string; sub: string }): string { + return this.jwtService.sign(payload); + } + + private createRefreshToken(): { token: string; expiry: Date } { + const token = randomBytes(48).toString('hex'); + const expiry = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + return { token, expiry }; + } + private async verifySignature( publicKey: string, signature: string, From 86770d55ed60825defd3f0a687a669963e12716f Mon Sep 17 00:00:00 2001 From: Nafiu Ishaq <63783380+nafiuishaaq@users.noreply.github.com> Date: Sun, 21 Jun 2026 04:51:10 +0000 Subject: [PATCH 5/7] implemented the refresh token test --- src/modules/auth/auth.controller.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index 4168dee..5643f39 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, Post, Body } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger'; import { AuthService } from './auth.service'; import { LoginDto } from './dto/login.dto'; +import { RefreshDto } from './dto/refresh.dto'; @ApiTags('auth') @Controller('auth') @@ -11,13 +12,23 @@ export class AuthController { @Post('login') @ApiOperation({ summary: 'Login with Stellar wallet signature' }) @ApiBody({ type: LoginDto }) - @ApiResponse({ status: 201, description: 'Login successful, returns access token' }) + @ApiResponse({ status: 201, description: 'Login successful, returns access and refresh tokens' }) @ApiResponse({ status: 401, description: 'Invalid signature or wallet not found' }) @ApiResponse({ status: 400, description: 'Invalid request body' }) async login(@Body() loginDto: LoginDto) { return this.authService.login(loginDto); } + @Post('refresh') + @ApiOperation({ summary: 'Rotate refresh token and issue a new access token' }) + @ApiBody({ type: RefreshDto }) + @ApiResponse({ status: 200, description: 'Refresh successful, returns new access and refresh tokens' }) + @ApiResponse({ status: 401, description: 'Invalid or expired refresh token' }) + @ApiResponse({ status: 400, description: 'Invalid request body' }) + async refresh(@Body() refreshDto: RefreshDto) { + return this.authService.refresh(refreshDto.refreshToken); + } + @Get() @ApiOperation({ summary: 'Get auth module status' }) @ApiResponse({ status: 200, description: 'Module status' }) From a4039ff782d6471eae0e9cec1b1e7dd0593cbf93 Mon Sep 17 00:00:00 2001 From: Nafiu Ishaq <63783380+nafiuishaaq@users.noreply.github.com> Date: Sun, 21 Jun 2026 04:52:02 +0000 Subject: [PATCH 6/7] implemented the refresh token test --- src/modules/auth/auth.service.spec.ts | 126 ++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 src/modules/auth/auth.service.spec.ts diff --git a/src/modules/auth/auth.service.spec.ts b/src/modules/auth/auth.service.spec.ts new file mode 100644 index 0000000..d7ce6f9 --- /dev/null +++ b/src/modules/auth/auth.service.spec.ts @@ -0,0 +1,126 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { AuthService } from './auth.service'; +import { createMockRepository, MockRepository } from '../../common/mocks/repository.mock'; +import { User } from '../users/user.entity'; +import { Wallet } from '../wallet/wallet.entity'; + +describe('AuthService', () => { + let authService: AuthService; + let mockWalletRepo: MockRepository; + let mockUserRepo: MockRepository; + let jwtService: JwtService; + + beforeEach(() => { + mockWalletRepo = createMockRepository(); + mockUserRepo = createMockRepository(); + jwtService = { sign: jest.fn().mockReturnValue('mock-access-token') } as unknown as JwtService; + + authService = new AuthService( + mockWalletRepo as unknown as any, + mockUserRepo as unknown as any, + jwtService, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should login and issue access and refresh tokens', async () => { + const testUser: User = { + id: '123e4567-e89b-12d3-a456-426614174000', + email: 'test@example.com', + name: 'Test User', + role: 'user', + isSuspended: false, + suspensionReason: null, + refreshToken: null, + refreshTokenExpiry: null, + createdAt: new Date(), + updatedAt: new Date(), + } as User; + + const testWallet: Wallet = { + id: 'wallet-id', + publicKey: 'GTESTPUBLICKEY', + userId: testUser.id, + createdAt: new Date(), + } as Wallet; + + mockWalletRepo.findOne.mockResolvedValue(testWallet); + mockUserRepo.findOne.mockResolvedValue(testUser); + mockUserRepo.update.mockResolvedValue(testUser as any); + + const result = await authService.login({ + publicKey: testWallet.publicKey, + signature: 'dummy-signature', + message: 'dummy-message', + }); + + expect(result.accessToken).toBe('mock-access-token'); + expect(result.refreshToken).toBeDefined(); + expect(result.publicKey).toBe(testWallet.publicKey); + expect(result.userId).toBe(testUser.id); + expect(mockUserRepo.update).toHaveBeenCalledWith(testUser.id, expect.objectContaining({ + refreshToken: expect.any(String), + refreshTokenExpiry: expect.any(Date), + })); + }); + + it('should refresh and rotate the refresh token', async () => { + let currentRefreshToken = 'old-refresh-token'; + const testUser: User = { + id: '123e4567-e89b-12d3-a456-426614174000', + email: 'test@example.com', + name: 'Test User', + role: 'user', + isSuspended: false, + suspensionReason: null, + refreshToken: currentRefreshToken, + refreshTokenExpiry: new Date(Date.now() + 10000), + createdAt: new Date(), + updatedAt: new Date(), + } as User; + + const testWallet: Wallet = { + id: 'wallet-id', + publicKey: 'GTESTPUBLICKEY', + userId: testUser.id, + createdAt: new Date(), + } as Wallet; + + mockUserRepo.findOne.mockImplementation(async ({ where }) => { + if (where?.refreshToken === currentRefreshToken) { + return testUser; + } + return null; + }); + + mockWalletRepo.findOne.mockResolvedValue(testWallet); + mockUserRepo.update.mockImplementation(async (_id, update) => { + currentRefreshToken = update.refreshToken as string; + testUser.refreshToken = update.refreshToken as string; + testUser.refreshTokenExpiry = update.refreshTokenExpiry as Date; + return testUser as any; + }); + + const firstResult = await authService.refresh(currentRefreshToken); + + expect(firstResult.accessToken).toBe('mock-access-token'); + expect(firstResult.refreshToken).toBeDefined(); + expect(firstResult.refreshToken).not.toBe('old-refresh-token'); + expect(mockUserRepo.update).toHaveBeenCalledWith(testUser.id, expect.objectContaining({ + refreshToken: firstResult.refreshToken, + refreshTokenExpiry: expect.any(Date), + })); + + await expect(authService.refresh('old-refresh-token')).rejects.toThrow(UnauthorizedException); + }); + + it('should reject an invalid refresh token', async () => { + mockUserRepo.findOne.mockResolvedValue(null); + + await expect(authService.refresh('invalid-token')).rejects.toThrow(UnauthorizedException); + }); +}); From 51ba9ef95a73af070ca6956377c9afe65ffa3f5d Mon Sep 17 00:00:00 2001 From: Nafiu Ishaq <63783380+nafiuishaaq@users.noreply.github.com> Date: Sun, 21 Jun 2026 04:54:10 +0000 Subject: [PATCH 7/7] implemented the refresh token test --- src/modules/auth/auth.service.spec.ts | 18 ++++++++++-------- .../transactions/dto/query-transactions.dto.ts | 1 - 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/modules/auth/auth.service.spec.ts b/src/modules/auth/auth.service.spec.ts index d7ce6f9..69df1fe 100644 --- a/src/modules/auth/auth.service.spec.ts +++ b/src/modules/auth/auth.service.spec.ts @@ -50,7 +50,7 @@ describe('AuthService', () => { mockWalletRepo.findOne.mockResolvedValue(testWallet); mockUserRepo.findOne.mockResolvedValue(testUser); - mockUserRepo.update.mockResolvedValue(testUser as any); + mockUserRepo.update.mockResolvedValue(testUser); const result = await authService.login({ publicKey: testWallet.publicKey, @@ -90,19 +90,21 @@ describe('AuthService', () => { createdAt: new Date(), } as Wallet; - mockUserRepo.findOne.mockImplementation(async ({ where }) => { - if (where?.refreshToken === currentRefreshToken) { + mockUserRepo.findOne.mockImplementation(async (criteria: { where?: { refreshToken?: string } }) => { + if (criteria.where?.refreshToken === currentRefreshToken) { return testUser; } return null; }); mockWalletRepo.findOne.mockResolvedValue(testWallet); - mockUserRepo.update.mockImplementation(async (_id, update) => { - currentRefreshToken = update.refreshToken as string; - testUser.refreshToken = update.refreshToken as string; - testUser.refreshTokenExpiry = update.refreshTokenExpiry as Date; - return testUser as any; + mockUserRepo.update.mockImplementation(async (_id: string, update: Partial) => { + const refreshToken = update.refreshToken as string; + const refreshTokenExpiry = update.refreshTokenExpiry as Date; + currentRefreshToken = refreshToken; + testUser.refreshToken = refreshToken; + testUser.refreshTokenExpiry = refreshTokenExpiry; + return testUser; }); const firstResult = await authService.refresh(currentRefreshToken); diff --git a/src/modules/transactions/dto/query-transactions.dto.ts b/src/modules/transactions/dto/query-transactions.dto.ts index 382a840..78622f5 100644 --- a/src/modules/transactions/dto/query-transactions.dto.ts +++ b/src/modules/transactions/dto/query-transactions.dto.ts @@ -8,7 +8,6 @@ import { IsIn, IsUUID, IsDate, - IsPositive, } from 'class-validator'; import { Type } from 'class-transformer';